feat(ui): add composer Stop and simplify task controls (#13104)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task composer is where operators direct running agents.
> - Operators need to stop work without leaving the conversation.
> - Existing pause controls already hold task trees and interrupt both
runner types.
> - This pull request connects the composer to those controls and
removes repeated feedback.
> - Operators can pause work quickly and still queue messages while
agents run.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Task pause, resume, and cancellation in the task page and composer.

**Current behavior**

The empty composer cannot stop a running task. Task controls require
extra confirmation and reason text. Pause can show several notifications
for the task already on screen.

**Proposed behavior**

Show Stop while this task runs and the composer is empty. Text or
attachments switch it to Send. Stop and the menu use the same manual
pause hold. Parent pauses include descendants. Keep task cancellation in
the menu with a compact confirmation. Show one quiet pause row and gray
cancelled-run details.

**Reason and benefit**

Operators can interrupt execution with one click. Drafts and queued
messages keep their existing behavior. The UI waits for actual
termination, including native cancellation acknowledgment.

**Breaking changes**

No endpoint, schema, or task-status change. Pause no longer asks for
confirmation or a reason. Resume now honors the existing wake-agents
option. Task notifications are suppressed for the task and subtree
currently in view.

Related UI work: #8228 changes navigation and composer shortcuts. This
PR covers execution controls. No duplicate Stop-button PR was found. The
change improves existing controls and does not duplicate a roadmap
milestone.

## What Changed

- Add Stop, pending feedback, duplicate-click protection, and inline
errors to the composer.
- Share the pause mutation across the composer, active-run controls, and
menu.
- Poll affected runs after a pause request. Require native cancellation
acknowledgment.
- Remove pause confirmation and shared reason fields. Reduce cancel
confirmation to its task count and actions.
- Honor wake-agents for executable tasks only. Preserve the pause when
recovery review is needed; show partial wake failures inline.
- Preserve explicit legacy reconciliation decisions while their
continuation waits for dispatch.
- Suppress notifications for visible task trees. Use quiet pause and
cancellation feedback.
- Add interactive stories using production controls and native/legacy
end-to-end tests.

## Verification

- User reviewed the running feature and revised Storybooks in the
browser.
- Rebased focused checks passed: 295 original targeted tests, 161
updated route/page/notification/status tests, and 26 recovery
integration tests.
- Both isolated runner journeys pass on the final revision (1.7
minutes). Coverage includes queueing, parent and child interruption,
persisted holds, no automatic continuation, reconciled resume,
cancellation, terminal exclusions, and no Stop toast.
- Native coverage uses real runnerd with a deterministic provider
fixture. Legacy coverage checks actual process termination. Live
hosted-provider execution was not tested.
- Repository typecheck and build, Storybook build, and token gates
passed after rebase. The final server typecheck/build also passed.
- The broad local run completed its general-server stage with 7,219
passing tests, 48 skipped, and two failures from cached pre-fix source
and a stale native provider fixture. Both failed tests pass in fresh
final-head reruns after rebuilding the fixture; the script did not
continue to its later local stages. CI runs all test groups on the final
revision.
- Final revision: all 31 applicable CI checks passed; Storybook visual
regression was skipped by its workflow conditions. Greptile: 5/5, zero
unresolved comments.
- Review `Tasks / Execution Controls` in Storybook. Type and clear a
draft, stop a run, expand cancellation details, and test the menu on
desktop and mobile.

## Risks

- Stop pauses descendants for a parent task. This is the existing pause
contract.
- A held task can remain active if interruption fails. The UI shows an
error instead of claiming termination.
- Resume can start multiple assignees when wake-agents is selected.
Backlog, blocked, and terminal tasks stay excluded. Existing execution
reconciliation remains mandatory where required; Resume never invents
action-outcome evidence.
- Notification suppression uses the visible task and cached subtree.
Notifications for unrelated work remain enabled.

## Model Used

OpenAI GPT-6 through Codex. The exact runtime snapshot and
context-window limit are not exposed in this session. Used reasoning,
tool calls, code execution, and browser inspection.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 12:18:56 -05:00 committed by GitHub
parent 622376e995
commit 8cfd30fb07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2481 additions and 814 deletions

View File

@ -35,6 +35,14 @@ Existing tiers already in index.css (~80+ tokens) — extraction maps to these o
7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run.
8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files."
## Contextual feedback
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.”
## Enforcement (what "compliant" means for the extraction run)
- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved.

155
doc/composer-stop.md Normal file
View File

@ -0,0 +1,155 @@
# Composer Stop and task controls
The empty composer shows **Stop** while this task has a live execution and the
viewer can manage task controls. Stop creates the same manual pause hold as
**Pause work** / **Pause subtree** in the task menu. A parent pause includes its
descendants. Cancellation remains a separate menu action.
Text (after trimming) or attachments switch the button back to Send. Uploading
and failed attachments retain their existing restrictions. Keyboard submission
never invokes Stop. Queued-message editing and structured interactions keep
their existing actions, and text entered while stopping remains in the draft.
Pause dispatches without a preview dialog or reason. The button stays pending
while affected run state is checked; native cancellation must be acknowledged.
A saved hold with unconfirmed termination produces an error rather than a
success claim. The cancellation dialog requires a valid preview and excludes
terminal tasks. Resume/restore retain the optional wake-agents checkbox.
## Resume and execution recovery
Resume releases a hold. Waking agents is optional and only applies to tasks in
`todo`, `in_progress`, or `in_review`; parked and terminal tasks stay untouched.
The current execution-recovery policy requires verified outcomes before a stopped
provider can restart. If any affected task still needs that reconciliation,
Resume with wake enabled returns an inline error and preserves the pause. The
operator can release the pause without waking agents, then use the existing
execution-reconciliation flow after reviewing the stopped run. Resume does not
claim that unknown provider actions completed or were never performed.
A completed release remains successful if a best-effort wake fails. Its response
includes optional `wakeFailures`, the page reports them inline, and remaining
eligible tasks still receive their wake requests. No new endpoint is introduced.
The deterministic E2E fixtures prove interruption, then record their known lack
of external effects through the existing reconciliation API before continuing.
## Quiet task feedback
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.
This is recorded as a product rule in `DESIGN.md`.
The follow-up passed 213 focused tests, both isolated runner E2E journeys
(including no-toast assertions), UI typecheck/build, token gates, and Storybook
build. Paused, expanded cancellation, Stop without toasts, mobile, and light
stories were inspected in the browser.
## Storybook
Run from the worktree:
```sh
pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open
```
Open `http://localhost:6016/?path=/story/tasks-execution-controls--running-empty`.
The `Tasks / Execution Controls` stories compose the production composer and
menu/dialog controls together. They cover text switching, attachment-only,
idle, stopping, paused, errors, cancellation preview/loading, and mobile/light
presentations. The Storybook state transitions simulate requests; runner
verification belongs to the isolated browser suite below.
## Automated verification
```sh
pnpm exec vitest run --project @paperclipai/ui ui/src/components/task-chat/TaskChatComposer.test.tsx ui/src/components/TaskChatThread.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/lib/wait-for-stopped-runs.test.ts
pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-tree-control-routes.test.ts
pnpm check:token-gates
pnpm build-storybook
pnpm -r typecheck
pnpm test:run
pnpm build
```
The component/page tests cover whitespace, attachments and upload restrictions,
permissions, keyboard submission, queue edits, duplicate clicks, draft
preservation, shared pause requests, compact cancellation, and visible errors.
Stop verification tests include continued execution, native acknowledgment,
network failures, and hung status requests. Route tests cover pause dispatch,
authorization, cancellation, and opt-in resume wakeups with terminal/company
exclusions.
## Isolated browser acceptance
Legacy process coverage needs no provider credentials:
```sh
pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts
```
To include native execution, build the real runner and deterministic Codex
protocol fixture from this checkout, then point the suite at their absolute
paths:
```sh
cargo build --manifest-path packages/paperclip-runner/runner/Cargo.toml --bin paperclip-runnerd --bin fake-codex-app-server
PAPERCLIP_STOP_FAKE_CODEX="$PWD/packages/paperclip-runner/runner/target/debug/fake-codex-app-server" \
PAPERCLIP_RUNNER_BINARY="$PWD/packages/paperclip-runner/runner/target/debug/paperclip-runnerd" \
pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts
```
The suite boots a disposable local-trusted instance on port 3199 (override with
`PAPERCLIP_E2E_PORT`). It never attaches to an existing server. Native coverage
is explicitly skipped without the fixture; it must not use a logged-in provider
as a fallback. Test companies are archived during cleanup.
For each runner, the journey starts a parent, child, and unrelated task, plus a
terminal child. It sends while running and verifies the durable queue, clicks
Stop, verifies interruption and the persisted hold, and observes three
ten-second scheduler intervals without continuation. It resumes with wakeups,
pauses from the menu, dismisses and then confirms cancellation, and verifies
terminal-task exclusions and unrelated execution.
Timing attachments distinguish click-to-request from request-to-observed-stop.
Native proof requires provider `turn/start` before the click, `turn/interrupt`
after it, and durable cancellation acknowledgment. Legacy proof checks the
actual parent and child PIDs have exited with a one-second configured grace
period. HTTP success alone is insufficient.
For a release with real providers, repeat while an actual long-running tool is
active, confirm the tool's own process/output stops, and verify a queued message
is consumed on continuation. The deterministic provider exercises the native
transport and cancellation protocol, not external provider behavior or every
tool's process cleanup. Preserve these limits in any test report.
## Recorded acceptance run (2026-09-09)
Both isolated browser journeys passed. Legacy Stop dispatched after 214 ms and
observed both runs stopped 155 ms after dispatch; native dispatched after 212 ms
and observed stop 320 ms later. These are single local observations including
browser automation overhead, not latency guarantees. Native used the real
runnerd and the repository's deterministic Codex protocol fixture. Real hosted
provider/tool cleanup remains a release acceptance check.
Repository typecheck, production build, Storybook build, token gates, targeted
UI tests, and tree-control route tests passed. The broad UI run passed 5,491
tests with one five-second timeout in an unchanged `IssuesList` test; rerunning
that file passed all 46 tests. The repository test run encountered 17 failures
in unchanged suites, all reproduced in the original checkout:
- `server/src/__tests__/workspace-runtime.test.ts`: 2 failures.
- `server/src/services/workspace-runtime-exposure.test.ts`: 7 failures.
- `server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts`: 4 failures.
- `server/src/__tests__/workspace-instance-cleanup.test.ts`: 1 failure.
- `server/src/__tests__/company-skills.test.ts`: 2 failures.
- `server/src/__tests__/worktree-seed-server-spawn.test.ts`: 1 failure.
These baseline failures prevent a green repository-wide test result.
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.

View File

@ -1220,6 +1220,7 @@ export type {
IssueLabel,
IssueTreeControlPreview,
IssueTreeHold,
ReleaseIssueTreeHoldResponse,
IssueTreeHoldMember,
IssueTreeHoldReleasePolicy,
IssueTreePreviewAgent,

View File

@ -788,6 +788,7 @@ export type {
export type {
IssueTreeControlPreview,
IssueTreeHold,
ReleaseIssueTreeHoldResponse,
IssueTreeHoldMember,
IssueTreeHoldReleasePolicy,
IssueTreePreviewAgent,

View File

@ -113,3 +113,8 @@ export interface IssueTreeHold {
updatedAt: Date;
members?: IssueTreeHoldMember[];
}
/** A completed release can include best-effort wake failures. */
export interface ReleaseIssueTreeHoldResponse extends IssueTreeHold {
wakeFailures?: Array<{ issueId: string; message: string }>;
}

View File

@ -19,6 +19,11 @@ function run(status: string, resultJson: Record<string, unknown> | null) {
}
describe("buildHeartbeatRunStatusLiveEventPayload", () => {
it("identifies the task after its live-run cache entry has disappeared", () => {
expect(buildHeartbeatRunStatusLiveEventPayload({
...run("cancelled", null), contextSnapshot: { issueId: "task-1" },
}).issueId).toBe("task-1");
});
it("attaches the canonical final assistant text to terminal status events", () => {
expect(
buildHeartbeatRunStatusLiveEventPayload(

View File

@ -1,4 +1,5 @@
import express from "express";
import { PgDialect } from "drizzle-orm/pg-core";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -16,6 +17,9 @@ const mockTreeControlService = vi.hoisted(() => ({
cancelUnclaimedWakeupsForTree: vi.fn(),
}));
const mockReplayBlocks = vi.hoisted(() => vi.fn());
const mockReplayWhere = vi.hoisted(() => vi.fn());
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockHeartbeatService = vi.hoisted(() => ({
cancelRun: vi.fn(),
@ -40,7 +44,13 @@ async function createApp(actor: Record<string, unknown>) {
(req as any).actor = actor;
next();
});
app.use("/api", issueTreeControlRoutes({} as any));
const query = {
from: () => query,
innerJoin: () => query,
where: (predicate: unknown) => { mockReplayWhere(predicate); return query; },
limit: mockReplayBlocks,
};
app.use("/api", issueTreeControlRoutes({ select: () => query } as any));
app.use(errorHandler);
return app;
}
@ -48,12 +58,17 @@ async function createApp(actor: Record<string, unknown>) {
describe("issue tree control routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockReplayBlocks.mockResolvedValue([]);
mockTreeControlService.getHold.mockResolvedValue(null);
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-2",
});
mockTreeControlService.cancelUnclaimedWakeupsForTree.mockResolvedValue([]);
mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [] });
mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({
updatedIssueIds: [],
updatedIssues: [],
});
mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({
updatedIssueIds: [],
updatedIssues: [],
@ -64,6 +79,173 @@ describe("issue tree control routes", () => {
mockHeartbeatService.wakeup.mockResolvedValue(null);
});
it.each([false, true])(
"only wakes eligible current assignees when resume requests wakeAgents=%s",
async (wakeAgents) => {
const rootId = "11111111-1111-4111-8111-111111111111";
const holdId = "33333333-3333-4333-8333-333333333333";
const root = {
id: rootId,
companyId: "company-2",
status: "todo",
assigneeAgentId: "agent-parent",
};
const issues = [
root,
{
id: "child",
companyId: "company-2",
status: "in_progress",
assigneeAgentId: "agent-child",
},
{
id: "backlog-task",
companyId: "company-2",
status: "backlog",
assigneeAgentId: "parked-agent",
},
{
id: "blocked-task",
companyId: "company-2",
status: "blocked",
assigneeAgentId: "blocked-agent",
},
{
id: "done",
companyId: "company-2",
status: "done",
assigneeAgentId: "agent-done",
},
{
id: "cancelled",
companyId: "company-2",
status: "cancelled",
assigneeAgentId: "agent-cancelled",
},
{
id: "foreign",
companyId: "company-3",
status: "todo",
assigneeAgentId: "agent-foreign",
},
];
mockIssueService.getById.mockImplementation(async (id: string) =>
issues.find((issue) => issue.id === id),
);
mockTreeControlService.releaseHold.mockResolvedValue({
id: holdId,
mode: "pause",
status: "released",
members: issues.map((issue) => ({ issueId: issue.id })),
});
mockTreeControlService.getHold.mockResolvedValue({
id: holdId,
rootIssueId: rootId,
mode: "pause",
members: issues.map((issue) => ({ issueId: issue.id })),
});
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: ["company-2"],
source: "session",
isInstanceAdmin: false,
});
const response = await request(app)
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
.send({ metadata: { wakeAgents } });
expect(response.status).toBe(200);
expect(mockHeartbeatService.wakeup.mock.calls.map(([id]) => id)).toEqual(
wakeAgents ? ["agent-parent", "agent-child"] : [],
);
if (wakeAgents) {
const { params } = new PgDialect().sqlToQuery(mockReplayWhere.mock.calls[0]![0]);
expect(params).toEqual(expect.arrayContaining(["todo", "in_progress", "in_review"]));
expect(params).not.toContain("blocked");
expect(params).not.toContain("backlog");
}
if (wakeAgents)
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"agent-child",
expect.objectContaining({
reason: "issue_tree_resumed",
contextSnapshot: expect.objectContaining({
issueId: "child",
holdId,
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.tree_hold_released" }),
);
},
);
it("reports wake failures without undoing release or skipping other assignees", async () => {
const rootId = "11111111-1111-4111-8111-111111111111";
const holdId = "33333333-3333-4333-8333-333333333333";
mockTreeControlService.releaseHold.mockResolvedValue({
id: holdId,
mode: "pause",
status: "released",
members: [{ issueId: rootId }, { issueId: "child" }],
});
mockIssueService.getById.mockImplementation(async (id) => ({
id,
companyId: "company-2",
status: "todo",
assigneeAgentId: id,
}));
mockHeartbeatService.wakeup.mockRejectedValueOnce(
new Error("Agent unavailable"),
);
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: ["company-2"],
source: "session",
isInstanceAdmin: false,
});
const response = await request(app)
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
.send({ metadata: { wakeAgents: true } });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
status: "released",
wakeFailures: [{ issueId: rootId, message: "Agent unavailable" }],
});
expect(
mockHeartbeatService.wakeup.mock.calls.map(([agent]) => agent),
).toEqual([rootId, "child"]);
});
it("keeps the hold active when resume requests an unsafe execution replay", async () => {
const rootId = "11111111-1111-4111-8111-111111111111";
const holdId = "33333333-3333-4333-8333-333333333333";
mockTreeControlService.getHold.mockResolvedValue({
id: holdId,
rootIssueId: rootId,
mode: "pause",
members: [{ issueId: rootId }],
});
mockReplayBlocks.mockResolvedValue([{ identifier: "TEST-1" }]);
const app = await createApp({
type: "board",
userId: "user-1",
companyIds: ["company-2"],
source: "session",
isInstanceAdmin: false,
});
const response = await request(app)
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
.send({ metadata: { wakeAgents: true } });
expect(response.status).toBe(409);
expect(response.body.error).toContain("Resume without waking agents");
expect(mockTreeControlService.releaseHold).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("rejects cross-company preview requests with a uniform 404 before calling the preview service", async () => {
const app = await createApp({
type: "board",

View File

@ -1,17 +1,30 @@
import { Router } from "express";
import type { Request } from "express";
import type { Db } from "@paperclipai/db";
import {
issueRecoveryActions,
issues as issueRows,
type Db,
} from "@paperclipai/db";
import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
import { conflict } from "../errors.js";
import {
EXECUTION_RECONCILIATION_CAUSES,
createIssueTreeHoldSchema,
isUuidLike,
previewIssueTreeControlSchema,
releaseIssueTreeHoldSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js";
import {
heartbeatService,
issueService,
issueTreeControlService,
logActivity,
} from "../services/index.js";
import { assertBoard, getAccessibleResource, getActorInfo } from "./authz.js";
const TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS = 1_000;
const RESUME_EXECUTABLE_STATUSES = ["todo", "in_progress", "in_review"];
function errorToMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
@ -349,7 +362,12 @@ export function issueTreeControlRoutes(db: Db) {
validate(releaseIssueTreeHoldSchema),
async (req, res) => {
assertBoard(req);
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
const root = await getAccessibleResource(
req,
res,
resolveRootIssue(req),
"Root issue not found",
);
if (!root) return;
const holdId = req.params.holdId as string;
@ -358,17 +376,65 @@ export function issueTreeControlRoutes(db: Db) {
return;
}
// Releasing a pause does not authorize replay of uncertain provider actions.
// Check before release so a rejected wake leaves the subtree paused.
if (req.body.metadata?.wakeAgents === true) {
const activeHold = await treeControlSvc.getHold(root.companyId, holdId);
const issueIds =
activeHold?.mode === "pause" && activeHold.rootIssueId === root.id
? (activeHold.members ?? [])
.filter((member) => !member.skipped)
.map((member) => member.issueId)
: [];
if (issueIds.length > 0) {
const [blocked] = await db
.select({ identifier: issueRows.identifier })
.from(issueRecoveryActions)
.innerJoin(
issueRows,
and(
eq(issueRows.id, issueRecoveryActions.sourceIssueId),
eq(issueRows.companyId, root.companyId),
),
)
.where(
and(
eq(issueRecoveryActions.companyId, root.companyId),
inArray(issueRecoveryActions.sourceIssueId, issueIds),
inArray(issueRows.status, RESUME_EXECUTABLE_STATUSES),
isNotNull(issueRows.assigneeAgentId),
inArray(issueRecoveryActions.cause, [
...EXECUTION_RECONCILIATION_CAUSES,
]),
or(
inArray(issueRecoveryActions.status, ["active", "escalated"]),
sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`,
),
),
)
.limit(1);
if (blocked)
throw conflict(
`Cannot wake ${blocked.identifier ?? "this task"} until its stopped execution is reconciled. Resume without waking agents, or review the stopped run first.`,
);
}
}
const actor = getActorInfo(req);
const hold = await treeControlSvc.releaseHold(root.companyId, root.id, holdId, {
...req.body,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
const hold = await treeControlSvc.releaseHold(
root.companyId,
root.id,
holdId,
{
...req.body,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
},
});
);
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
@ -387,7 +453,65 @@ export function issueTreeControlRoutes(db: Db) {
},
});
res.json(hold);
const wakeFailures: Array<{ issueId: string; message: string }> = [];
if (hold.mode === "pause" && req.body.metadata?.wakeAgents === true) {
for (const member of hold.members ?? []) {
if (member.skipped) continue;
try {
const issue = await issuesSvc.getById(member.issueId);
if (
!issue ||
issue.companyId !== root.companyId ||
!issue.assigneeAgentId ||
!RESUME_EXECUTABLE_STATUSES.includes(issue.status)
)
continue;
await heartbeat.wakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_tree_resumed",
payload: {
issueId: issue.id,
rootIssueId: root.id,
holdId: hold.id,
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: issue.id,
taskId: issue.id,
wakeReason: "issue_tree_resumed",
source: "issue.tree_resume",
rootIssueId: root.id,
holdId: hold.id,
},
});
} catch (error) {
const message = errorToMessage(error);
wakeFailures.push({ issueId: member.issueId, message });
await Promise.resolve(
logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.tree_resume_wake_failed",
entityType: "issue",
entityId: root.id,
details: {
holdId: hold.id,
issueId: member.issueId,
error: message,
},
}),
).catch(() => null);
}
}
}
res.json({ ...hold, ...(wakeFailures.length ? { wakeFailures } : {}) });
},
);

View File

@ -20,6 +20,9 @@ export function buildHeartbeatRunStatusLiveEventPayload(
return {
runId: run.id,
agentId: run.agentId,
issueId: typeof run.contextSnapshot?.issueId === "string"
? run.contextSnapshot.issueId
: null,
status: run.status,
invocationSource: run.invocationSource,
triggerDetail: run.triggerDetail,

View File

@ -1,7 +1,7 @@
import { normalizeMaxTurnStopReason } from "./heartbeat-stop-metadata.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
import { heartbeatRuns, issueRecoveryActions, issues, type Db } from "@paperclipai/db";
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
@ -98,6 +98,16 @@ export async function terminalizeLegacyExecution(input: {
(task.assigneeAgentId === run.agentId || isCurrentReviewer) &&
!["done", "cancelled"].includes(task.status)
) {
// Periodic stranded-work checks may revisit this terminal run before its
// reconciled continuation is dispatched. Preserve the recorded decision.
const [reconciled] = await tx.select({ id: issueRecoveryActions.id })
.from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId),
eq(issueRecoveryActions.sourceIssueId, task.id),
eq(issueRecoveryActions.status, "resolved"),
sql`${issueRecoveryActions.evidence}->'executionReconciliation'->>'runId' = ${run.id}`,
)).limit(1);
if (reconciled) return updated;
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
companyId: run.companyId,
sourceIssueId: task.id,

View File

@ -202,6 +202,19 @@ const support = await getEmbeddedPostgresTestSupport();
expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })).toBe(false);
expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", scheduledRetryAttempt: 2, resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })).toBe(true);
});
it("does not reopen a reconciled legacy run while continuation is pending", async () => {
const source = await seed();
const [run] = await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "cancelled" }).where(eq(heartbeatRuns.id, source.runId)).returning();
await terminalizeLegacyExecution({ db, run, status: "cancelled" });
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
await markExecutionReconciliation(db, action!, { runId: source.runId, providerStopped: true, actionOutcome: "not_performed", outcomeEvidence: "The deterministic fixture has stopped and only printed output." }, "board");
await db.update(issueRecoveryActions).set({ status: "resolved" }).where(eq(issueRecoveryActions.id, action!.id));
await terminalizeLegacyExecution({ db, run, status: "cancelled" });
const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
expect(actions).toHaveLength(1);
expect(actions[0]).toMatchObject({ status: "resolved", evidence: { continuationDelivery: "pending", executionReconciliation: { runId: source.runId } } });
await db.update(issueRecoveryActions).set({ evidence: { ...actions[0]!.evidence, continuationDelivery: "invalidated" } }).where(eq(issueRecoveryActions.id, action!.id));
});
it("surfaces a failed current reviewer without transferring the original assignment", async () => {
const source = await seed();
const reviewerId = randomUUID();

View File

@ -0,0 +1,405 @@
import { readFile } from "node:fs/promises";
import {
test,
expect,
type APIRequestContext,
type APIResponse,
type Page,
} from "@playwright/test";
async function json(response: APIResponse) {
const text = await response.text();
expect(response.ok(), `${response.url()}: ${response.status()} ${text}`).toBe(
true,
);
return JSON.parse(text);
}
async function task(
request: APIRequestContext,
companyId: string,
data: Record<string, unknown>,
) {
return json(
await request.post(`/api/companies/${companyId}/issues`, {
data: { title: "Composer stop acceptance", status: "backlog", ...data },
}),
);
}
async function running(
request: APIRequestContext,
issueId: string,
adapter: "process" | "paperclip_runner",
) {
let run:
| { id: string; status: string; runtimeMode?: string; processPid?: number }
| undefined;
await expect
.poll(
async () => {
const runs = await json(
await request.get(`/api/issues/${issueId}/live-runs`),
);
run = runs.find(
(candidate: { status: string }) => candidate.status === "running",
);
return !!run;
},
{ timeout: 30_000 },
)
.toBe(true);
let fullRun = await json(await request.get(`/api/heartbeat-runs/${run!.id}`));
await expect
.poll(
async () => {
fullRun = await json(
await request.get(`/api/heartbeat-runs/${run!.id}`),
);
return fullRun.runtimeMode;
},
{ timeout: 15_000 },
)
.toBe(adapter === "process" ? "legacy" : "native");
if (adapter === "process") {
await expect
.poll(
async () => {
fullRun = await json(
await request.get(`/api/heartbeat-runs/${run!.id}`),
);
return fullRun.processPid;
},
{ timeout: 15_000 },
)
.toBeTruthy();
}
return fullRun;
}
async function reconcileDemoExecution(
request: APIRequestContext,
issueId: string,
runId: string,
) {
// These deterministic fixtures only print output. No external action occurred.
// Master requires recorded outcomes before a cancelled provider can restart.
const activity = await json(
await request.get(`/api/issues/${issueId}/activity`),
);
const settled = activity.find(
(entry: { action: string; runId: string }) =>
entry.action === "issue.execution_recovery_settled" &&
entry.runId === runId,
);
const recovery = await json(
await request.get(`/api/issues/${issueId}/recovery-actions`),
);
const actionId = recovery.active?.id ?? settled?.details?.recoveryActionId;
expect(actionId).toBeTruthy();
await json(
await request.post(`/api/issues/${issueId}/recovery-actions/resolve`, {
data: {
actionId,
outcome: "restored",
sourceIssueStatus: "todo",
executionReconciliation: {
runId,
providerStopped: true,
actionOutcome: "not_performed",
outcomeEvidence:
"The deterministic acceptance fixture only emits console/protocol output. The verified stopped process performed no external actions.",
},
},
}),
);
}
async function menu(page: Page, action: string) {
await page
.getByRole("button", { name: "More task actions", exact: true })
.click();
await page
.locator('[data-slot="popover-content"]')
.getByRole("button", { name: action, exact: true })
.click();
}
function processAlive(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
test.setTimeout(120_000);
for (const adapter of ["process", "paperclip_runner"] as const) {
test(`${adapter}: queue, composer Stop, subtree pause/cancel, and resume`, async ({
page,
request,
}, testInfo) => {
test.skip(
adapter === "paperclip_runner" && !process.env.PAPERCLIP_STOP_FAKE_CODEX,
"Set PAPERCLIP_STOP_FAKE_CODEX and PAPERCLIP_RUNNER_BINARY for real runnerd with the deterministic provider.",
);
const company = await json(
await request.post("/api/companies", {
data: { name: `Composer Stop ${adapter} ${Date.now()}` },
}),
);
const originalSettings = await json(
await request.get("/api/instance/settings/experimental"),
);
try {
await json(
await request.patch("/api/instance/settings/experimental", {
data: { enableClassicTaskInterface: false, enableNativeRunner: true },
}),
);
async function agent(name: string) {
return json(
await request.post(`/api/companies/${company.id}/agents`, {
data: {
name,
role: "engineer",
adapterType: adapter,
adapterConfig:
adapter === "process"
? {
command: process.execPath,
args: [
"-e",
"console.log('stop fixture ready'); setInterval(() => console.log('working'), 200);",
],
graceSec: 1,
}
: { provider: "codex", model: "gpt-5.1-codex-mini" },
runtimeConfig: {
heartbeat: { enabled: false, wakeOnDemand: true },
},
},
}),
);
}
const owner = await agent("Stop fixture parent");
const childOwner = await agent("Stop fixture child");
const otherOwner = await agent("Stop fixture unrelated");
const parent = await task(request, company.id, {
assigneeAgentId: owner.id,
});
const child = await task(request, company.id, {
title: "Child work",
parentId: parent.id,
assigneeAgentId: childOwner.id,
});
const completed = await task(request, company.id, {
title: "Finished child",
parentId: parent.id,
status: "done",
});
const other = await task(request, company.id, {
title: "Unrelated work",
assigneeAgentId: otherOwner.id,
});
for (const issue of [parent, child, other])
await json(
await request.patch(`/api/issues/${issue.id}`, {
data: { status: "todo" },
}),
);
const parentRun = await running(request, parent.id, adapter);
const childRun = await running(request, child.id, adapter);
const otherRun = await running(request, other.id, adapter);
if (adapter === "paperclip_runner") {
// A run row becomes live before its provider turn starts. Prove that the
// deterministic provider is active before attempting interruption.
await expect
.poll(
async () => {
const calls = await readFile(
process.env.PAPERCLIP_STOP_CODEX_LOG!,
"utf8",
).catch(() => "");
return calls.split("turn/start").length - 1;
},
{ timeout: 30_000 },
)
.toBeGreaterThanOrEqual(3);
}
expect(parentRun.runtimeMode).toBe(
adapter === "process" ? "legacy" : "native",
);
await page.goto(`/${company.issuePrefix}/issues/${parent.identifier}`);
const stop = page.getByRole("button", { name: "Stop", exact: true });
await expect(stop).toBeVisible({ timeout: 30_000 });
const editor = page.getByRole("textbox", { name: "editable markdown" });
await editor.fill("Please check mobile too.");
await expect(stop).toHaveCount(0);
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(stop).toBeVisible();
const comments = await json(
await request.get(`/api/issues/${parent.id}/comments`),
);
expect(JSON.stringify(comments)).toContain("Please check mobile too.");
const queue = await json(
await request.get(`/api/issues/${parent.id}/queued-comments`),
);
expect(JSON.stringify(queue.entries)).toContain(
"Please check mobile too.",
);
let dispatchedAt = 0;
page.on("request", (req) => {
if (req.method() === "POST" && req.url().endsWith("/tree-holds"))
dispatchedAt = Date.now();
});
const clickedAt = Date.now();
await stop.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Dismiss notification" }),
).toHaveCount(0);
for (const run of [parentRun, childRun]) {
await expect
.poll(
async () =>
(await json(await request.get(`/api/heartbeat-runs/${run.id}`)))
.status,
{ timeout: 35_000 },
)
.toBe("cancelled");
}
const stoppedAt = Date.now();
expect(dispatchedAt - clickedAt).toBeLessThan(2000);
expect(dispatchedAt).toBeGreaterThan(0);
if (adapter === "process") {
expect(parentRun.processPid).toBeTruthy();
await expect
.poll(() => processAlive(parentRun.processPid), { timeout: 3000 })
.toBe(false);
await expect
.poll(() => processAlive(childRun.processPid), { timeout: 3000 })
.toBe(false);
} else {
const finalRun = await json(
await request.get(`/api/heartbeat-runs/${parentRun.id}`),
);
expect(finalRun.resultJson?.nativeCancellation?.dispatchState).toBe(
"acknowledged",
);
expect(
await readFile(process.env.PAPERCLIP_STOP_CODEX_LOG!, "utf8"),
).toContain("turn/interrupt");
}
await testInfo.attach(`${adapter}-timing`, {
body: JSON.stringify({
clickToRequestMs: dispatchedAt - clickedAt,
requestToStoppedMs: stoppedAt - dispatchedAt,
}),
contentType: "application/json",
});
expect(
(
await json(
await request.get(`/api/issues/${parent.id}/tree-control/state`),
)
).activePauseHold,
).toBeTruthy();
expect(
(await json(await request.get(`/api/heartbeat-runs/${otherRun.id}`)))
.status,
).toBe("running");
await expect(
page.getByText("Subtree is paused.", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Dismiss notification" }),
).toHaveCount(0);
if (adapter === "paperclip_runner") {
await expect(
page.getByRole("button", { name: /^Run cancelled/ }),
).toHaveClass(/text-muted-foreground/);
}
await page.reload();
await expect(
page.getByText("Subtree is paused.", { exact: true }),
).toBeVisible();
// Cross the isolated server's ten-second scheduler interval repeatedly.
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 10_000));
expect(
await json(await request.get(`/api/issues/${parent.id}/live-runs`)),
).toEqual([]);
expect(
await json(await request.get(`/api/issues/${child.id}/live-runs`)),
).toEqual([]);
}
await menu(page, "Resume subtree");
await page.getByRole("dialog").getByRole("checkbox").check();
await page
.getByRole("dialog")
.getByRole("button", { name: "Resume subtree", exact: true })
.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
// The recovery policy parks these stopped tasks. Releasing the hold must
// leave them parked, even with Wake agents selected; no implicit replay.
expect(await json(await request.get(`/api/issues/${parent.id}/live-runs`))).toEqual([]);
expect(await json(await request.get(`/api/issues/${child.id}/live-runs`))).toEqual([]);
await reconcileDemoExecution(request, parent.id, parentRun.id);
await reconcileDemoExecution(request, child.id, childRun.id);
await running(request, parent.id, adapter);
await running(request, child.id, adapter);
await menu(page, "Pause subtree");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
page.getByText("Subtree is paused.", { exact: true }),
).toBeVisible();
await menu(page, "Cancel subtree...");
const dialog = page.getByRole("dialog");
await expect(
dialog.getByRole("heading", { name: "Cancel subtree?" }),
).toBeVisible();
await expect(
dialog.locator('textarea, input[type="checkbox"]'),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Keep tasks" }).click();
expect(
(await json(await request.get(`/api/issues/${parent.id}`))).status,
).not.toBe("cancelled");
await menu(page, "Cancel subtree...");
await dialog
.getByRole("button", { name: "Cancel 2 tasks", exact: true })
.click();
await expect
.poll(
async () =>
(await json(await request.get(`/api/issues/${child.id}`))).status,
)
.toBe("cancelled");
expect(
(await json(await request.get(`/api/issues/${parent.id}`))).status,
).toBe("cancelled");
expect(
(await json(await request.get(`/api/issues/${completed.id}`))).status,
).toBe("done");
expect(
(await json(await request.get(`/api/heartbeat-runs/${otherRun.id}`)))
.status,
).toBe("running");
await page.screenshot({
path: testInfo.outputPath(`${adapter}-cancelled.png`),
});
} finally {
// The company is disposable and scoped to this test invocation.
await request.patch(`/api/companies/${company.id}`, {
data: { status: "archived" },
});
await request.patch("/api/instance/settings/experimental", {
data: {
enableClassicTaskInterface:
originalSettings.enableClassicTaskInterface,
enableNativeRunner: originalSettings.enableNativeRunner,
},
});
}
});
}

View File

@ -0,0 +1,42 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { defineConfig } from "@playwright/test";
import base from "./playwright.config";
// Opt-in native coverage uses real runnerd with the repo's deterministic Codex
// protocol fixture. Never let this suite fall through to a logged-in real Codex.
const fixture = process.env.PAPERCLIP_STOP_FAKE_CODEX;
const fixtureDir = fs.mkdtempSync(
path.join(os.tmpdir(), "composer-stop-provider-"),
);
const logPath =
process.env.PAPERCLIP_STOP_CODEX_LOG ??
path.join(fixtureDir, "codex-calls.log");
process.env.PAPERCLIP_STOP_CODEX_LOG = logPath;
if (fixture) {
if (!path.isAbsolute(fixture) || !fs.existsSync(fixture))
throw new Error(
"PAPERCLIP_STOP_FAKE_CODEX must name the built fake-codex-app-server binary",
);
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
fs.writeFileSync(
path.join(fixtureDir, "codex"),
`#!/bin/sh\nexec ${quote(fixture)} --state-file ${quote(fixtureDir)}/state-$$.json --hold-turn --call-log ${quote(logPath)} "$@"\n`,
{ mode: 0o755 },
);
}
const server = base.webServer as Exclude<typeof base.webServer, unknown[]>;
export default defineConfig({
...base,
testMatch: "composer-stop.spec.ts",
timeout: 90_000,
webServer: {
...server,
env: {
...server?.env,
HEARTBEAT_SCHEDULER_INTERVAL_MS: "10000",
PATH: `${fixtureDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});

View File

@ -31,6 +31,7 @@ import type {
RunnerGoalProjection,
PreviewIssueTreeControl,
ReleaseIssueTreeHold,
ReleaseIssueTreeHoldResponse,
UpsertIssueWatchdog,
UpsertIssueDocument,
} from "@paperclipai/shared";
@ -223,7 +224,7 @@ export const issuesApi = {
} | null;
}>(`/issues/${id}/tree-control/state`),
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
api.post<ReleaseIssueTreeHoldResponse>(`/issues/${id}/tree-holds/${holdId}/release`, data),
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
retryScheduledRetryNow: (id: string) =>
api.post<IssueRetryNowResponse>(`/issues/${id}/scheduled-retry/retry-now`, {}),

View File

@ -176,6 +176,7 @@ import {
summarizeToolResult,
} from "../lib/transcriptPresentation";
import { buildAgentMentionHref } from "@paperclipai/shared";
import { useComposerStop } from "@/hooks/useComposerStop";
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { liveBlueBadge } from "../lib/status-colors";
import { nextWorkMode, titleForPendingWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta";
@ -418,6 +419,9 @@ export interface IssueChatComposerHandle {
}
interface IssueChatComposerProps {
onStop?: () => Promise<void>;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
draftKey?: string;
@ -510,6 +514,8 @@ interface IssueChatThreadProps {
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onCancelRun?: () => Promise<void>;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
@ -3862,6 +3868,9 @@ function areIssueChatMessageRowPropsEqual(
}
const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
onStop,
stopPending,
stopScope = "leaf",
onImageUpload,
onAttachImage,
draftKey,
@ -3881,6 +3890,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
onWorkModeChange,
}, forwardedRef) {
const api = useAui();
const stopControl = useComposerStop(onStop, stopPending);
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
@ -3960,6 +3970,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
},
}), []);
const showStop =
!submitting && !attaching && body.trim().length === 0 &&
composerAttachments.length === 0 && Boolean(onStop || stopControl.stopping);
async function handleSubmit() {
const trimmed = body.trim();
if (!trimmed || submitting) return;
@ -4466,11 +4480,29 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
/>
) : null}
<Button size="sm" disabled={!canSubmit} onClick={() => void handleSubmit()}>
{submitting ? "Posting..." : "Send"}
</Button>
{showStop ? (
<Button
size="icon-sm"
disabled={stopControl.stopping}
onClick={() => void stopControl.stop()}
aria-label={stopControl.stopping ? "Stopping…" : "Stop"}
title={stopScope === "subtree" ? "Stop and pause subtree" : "Stop and pause task"}
>
{stopControl.stopping
? <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
: <Square className="h-4 w-4 fill-current" aria-hidden />}
</Button>
) : (
<Button size="sm" disabled={!canSubmit} onClick={() => void handleSubmit()}>
{submitting ? "Posting..." : "Send"}
</Button>
)}
</div>
{stopControl.error ? (
<p role="alert" className="text-xs text-destructive">{stopControl.error}</p>
) : null}
{/* No-assignee warning modal (PAP-128 C): replaces the old press-Send-again toast. */}
<AlertDialog open={noAssigneeDialogOpen} onOpenChange={setNoAssigneeDialogOpen}>
<AlertDialogContent
@ -4552,6 +4584,8 @@ export function IssueChatThread({
onVote,
onAdd,
onCancelRun,
stopPending,
stopScope,
onStopRun,
stopRunLabel,
stoppingRunLabel,
@ -5404,6 +5438,9 @@ export function IssueChatThread({
mentions={mentions}
agentMap={agentMap}
hasActiveRun={!!hasActiveRun}
onStop={hasActiveRun ? onCancelRun : undefined}
stopPending={stopPending}
stopScope={stopScope}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
composerDisabledReason={composerDisabledReason}

View File

@ -1056,6 +1056,8 @@ describe("TaskChatThread runtime transcript selection", () => {
);
expect(container.textContent).toContain("Work was in progress.");
expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] button')?.classList).toContain("text-muted-foreground");
expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] .text-destructive')).toBeNull();
expect(
container.querySelector('[data-testid="task-chat-collapsible-marker"]')
?.textContent,
@ -2985,3 +2987,25 @@ describe("TaskChatThread live transcript", () => {
expect(container.textContent).toContain("Worked");
});
});
describe("TaskChatThread composer execution controls", () => {
it.each(["process", "paperclip_runner"])("passes the task's stop action through for %s execution", async (adapterType) => {
const onStop = vi.fn(async () => {});
const run = {
id: "task-run", status: "running", runtimeMode: adapterType === "process" ? "legacy" as const : "native" as const,
invocationSource: "issue", triggerDetail: null, startedAt: "2026-09-09T12:00:00Z", finishedAt: null,
createdAt: "2026-09-09T12:00:00Z", agentId: "agent-1", agentName: "Alex", adapterType,
};
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopScope="subtree" />);
const button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')!;
expect(button.title).toBe("Stop and pause subtree");
await act(async () => { button.click(); });
expect(onStop).toHaveBeenCalledOnce();
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopPending />);
expect(container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')?.disabled).toBe(true);
});
it("does not offer Stop for settled work even when a callback is available", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="todo" onCancelRun={vi.fn()} />);
expect(container.querySelector('[data-testid="task-chat-composer-stop"]')).toBeNull();
});
});

View File

@ -478,6 +478,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
userProfileMap,
currentUserId,
onAdd,
onCancelRun,
stopPending,
stopScope,
issueWorkMode = "standard",
onWorkModeChange,
composerAccessory,
@ -1455,6 +1458,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
id,
kind: "marker",
variant: "interrupted",
tone: source.status === "cancelled" ? "neutral" : "error",
label,
detail,
collapsible: true,
@ -2736,6 +2740,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div className="relative z-10">
<TaskChatComposer
onAdd={handleThreadAdd}
onStop={liveRun ? onCancelRun : undefined}
stopPending={stopPending}
stopScope={stopScope}
workMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
disabled={Boolean(runtimeComposerDisabledReason)}

View File

@ -0,0 +1,226 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { PauseCircle, PlayCircle, Repeat, XCircle } from "lucide-react";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
/** Shared by the task header and its Storybook composition. */
export function TaskTreeControlMenuItems({
scope,
canPause,
canResume,
canCancel,
canRestore,
pending,
onPause,
onResume,
onCancel,
onRestore,
}: {
scope: "leaf" | "subtree";
canPause: boolean;
canResume: boolean;
canCancel: boolean;
canRestore: boolean;
pending?: boolean;
onPause: () => void;
onResume: () => void;
onCancel: () => void;
onRestore: () => void;
}) {
const itemClass =
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 disabled:opacity-50 disabled:pointer-events-none";
return (
<>
{canPause ? (
<button disabled={pending} className={itemClass} onClick={onPause}>
<PauseCircle className="h-3 w-3" />
{scope === "leaf" ? "Pause work" : "Pause subtree"}
</button>
) : null}
{canResume ? (
<button disabled={pending} className={itemClass} onClick={onResume}>
<PlayCircle className="h-3 w-3" />
{scope === "leaf" ? "Resume work" : "Resume subtree"}
</button>
) : null}
{canCancel ? (
<button
disabled={pending}
className={`${itemClass} text-destructive`}
onClick={onCancel}
>
<XCircle className="h-3 w-3" />
Cancel subtree...
</button>
) : null}
{canRestore ? (
<button disabled={pending} className={itemClass} onClick={onRestore}>
<Repeat className="h-3 w-3" />
Restore subtree...
</button>
) : null}
</>
);
}
export function TaskTreeControlDialog({
open,
onOpenChange,
mode,
scope,
affectedCount,
affectedAgentCount,
loading,
error,
pending,
valid,
wakeAgents,
onWakeAgentsChange,
onRetry,
onApply,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "resume" | "cancel" | "restore";
scope: "leaf" | "subtree";
affectedCount: number;
affectedAgentCount: number;
loading: boolean;
error?: string | null;
pending: boolean;
valid: boolean;
wakeAgents: boolean;
onWakeAgentsChange: (wake: boolean) => void;
onRetry: () => void;
onApply: () => void;
}) {
const cancel = mode === "cancel";
const tasks = `${affectedCount} task${affectedCount === 1 ? "" : "s"}`;
const title = cancel
? "Cancel subtree?"
: mode === "restore"
? "Restore subtree"
: scope === "leaf"
? "Resume work"
: "Resume subtree";
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!pending) onOpenChange(next);
}}
>
<DialogContent
showCloseButton={!pending}
className="max-h-(--sz-calc-18) overflow-y-auto sm:max-w-sm"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{loading
? "Loading…"
: cancel
? `${tasks} will be cancelled.`
: `${tasks} will ${mode === "restore" ? "be restored" : "resume"}.`}
</DialogDescription>
</DialogHeader>
{error ? (
<div className="space-y-2">
<p role="alert" className="text-sm text-destructive">
{error}
</p>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={onRetry}
>
Retry preview
</Button>
</div>
) : null}
{!cancel ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={wakeAgents}
disabled={pending || loading || affectedAgentCount === 0}
onChange={(event) => onWakeAgentsChange(event.target.checked)}
/>
Wake affected agents ({affectedAgentCount})
</label>
) : null}
<DialogFooter>
<Button
variant="outline"
disabled={pending}
onClick={() => onOpenChange(false)}
>
{cancel ? "Keep tasks" : "Close"}
</Button>
<Button
variant={cancel ? "destructive" : "default"}
disabled={pending || loading || !!error || !valid}
onClick={onApply}
>
{pending
? "Applying…"
: cancel
? `Cancel ${tasks}`
: mode === "restore"
? `Restore ${tasks}`
: title}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Quiet, persistent pause feedback shared by the task page and previews. */
export function TaskPauseNotice({
scope,
onResume,
pending,
className,
resumeLink,
}: {
scope: "leaf" | "subtree";
onResume?: () => void;
pending?: boolean;
className?: string;
resumeLink?: ReactNode;
}) {
return (
<div
role="status"
className={cn(
"flex items-center justify-between gap-3 rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground",
className,
)}
>
<span>
{scope === "subtree" ? "Subtree is paused." : "Task is paused."}
</span>
{resumeLink ??
(onResume ? (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={onResume}
>
{scope === "subtree" ? "Resume subtree" : "Resume work"}
</Button>
) : null)}
</div>
);
}

View File

@ -2051,3 +2051,84 @@ describe("TaskChatComposer", () => {
});
});
});
describe("composer Stop", () => {
function stopButton() { return container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]'); }
it("switches Stop to Send with text, whitespace back to Stop, without interrupting on keyboard submit", async () => {
const onStop = vi.fn(async () => {});
const onAdd = vi.fn(async () => {});
render(<TaskChatComposer workMode="standard" onAdd={onAdd} onStop={onStop} stopScope="subtree" />);
expect(stopButton()?.title).toBe("Stop and pause subtree");
pressKey("Enter", { metaKey: true });
expect(onStop).not.toHaveBeenCalled();
expect(onAdd).not.toHaveBeenCalled();
typeText("Check mobile too.");
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined);
expect(onStop).not.toHaveBeenCalled();
typeText(" \n ");
expect(stopButton()?.disabled).toBe(false);
});
it("blocks duplicate stops and preserves text typed while stopping", async () => {
let resolve!: () => void;
const onStop = vi.fn(() => new Promise<void>((done) => { resolve = done; }));
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={onStop} />);
const stop = stopButton()!;
flushSync(() => { stop.click(); stop.click(); });
expect(onStop).toHaveBeenCalledTimes(1);
expect(stopButton()?.disabled).toBe(true);
expect(stopButton()?.getAttribute("aria-label")).toBe("Stopping…");
typeText("Keep this draft.");
expect(sendButton().disabled).toBe(false);
resolve();
await flushAsync();
expect(editable().textContent).toBe("Keep this draft.");
});
it("reports failure without discarding the draft and permits retry", async () => {
const onStop = vi.fn().mockRejectedValueOnce(new Error("Unable to stop. Try again.")).mockResolvedValue(undefined);
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={onStop} />);
flushSync(() => stopButton()!.click());
await flushAsync();
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unable to stop. Try again.");
flushSync(() => stopButton()!.click());
await flushAsync();
expect(onStop).toHaveBeenCalledTimes(2);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it("retains disabled Send when idle or when stop permission is absent", () => {
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} />);
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(true);
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={vi.fn()} disabled />);
expect(stopButton()?.disabled).toBe(true);
});
it("never turns a queued edit's save action into Stop", () => {
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={vi.fn()} queuedEdit={{ commentId: "queued", body: "" }} onSaveQueuedEdit={vi.fn()} />);
expect(stopButton()).toBeNull();
expect(sendButton().getAttribute("aria-label")).toBe("Save queued message");
});
it.each([false, true])("keeps attachments in send mode after upload (failed=%s)", async (failed) => {
let resolve!: (value: never) => void;
let reject!: (error: Error) => void;
const onAttachImage = vi.fn(() => new Promise<never>((done, fail) => { resolve = done; reject = fail; }));
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={vi.fn()} onAttachImage={onAttachImage} />);
pasteFiles([new File(["notes"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(true);
if (failed) reject(new Error("Upload failed"));
else resolve({ id: "attachment", contentPath: "/notes.txt", originalFilename: "notes.txt" } as never);
await flushAsync();
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(failed);
});
});

View File

@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { useComposerStop } from "@/hooks/useComposerStop";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import {
DRAFT_DEBOUNCE_MS,
@ -17,6 +18,7 @@ import {
} from "@/lib/composer-draft";
import {
ArrowUp,
Square,
Check,
ChevronDown,
CircleHelp,
@ -91,6 +93,9 @@ interface TaskChatComposerProps {
reopen?: boolean,
reassignment?: CommentReassignment,
) => Promise<void> | void;
onStop?: () => Promise<void>;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
workMode: IssueWorkMode;
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
disabled?: boolean;
@ -346,6 +351,9 @@ function escapeMarkdownLabel(name: string): string {
*/
export function TaskChatComposer({
onAdd,
onStop,
stopPending = false,
stopScope = "leaf",
workMode,
onWorkModeChange,
disabled = false,
@ -373,6 +381,7 @@ export function TaskChatComposer({
onRunnerGoalReassign,
}: TaskChatComposerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const stopControl = useComposerStop(onStop, stopPending);
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const [submitting, setSubmitting] = useState(false);
const [takeoverBusy, setTakeoverBusy] = useState(false);
@ -641,6 +650,12 @@ export function TaskChatComposer({
// Sending mid-upload would silently drop the pending file from the comment;
// sending past a failed chip would discard the file the user selected and
// clear its error state, so both hold submission until resolved or removed.
const showStop =
!queuedEdit &&
!submitting &&
body.trim().length === 0 &&
attachments.length === 0 &&
Boolean(onStop || stopControl.stopping);
const uploadPending = attachments.some((item) => item.status === "uploading");
const uploadFailed = attachments.some((item) => item.status === "error");
const takeoverVisible = Boolean(
@ -1194,31 +1209,43 @@ export function TaskChatComposer({
<button
type="button"
onClick={() => void submit()}
onClick={() => void (showStop ? stopControl.stop() : submit())}
disabled={
disabled ||
submitting ||
uploadPending ||
uploadFailed ||
(body.trim().length === 0 && attachedRefs.length === 0)
showStop
? disabled || stopControl.stopping
: disabled ||
submitting ||
uploadPending ||
uploadFailed ||
(body.trim().length === 0 && attachedRefs.length === 0)
}
title={
queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: uploadPending
? "Waiting for upload to finish"
: uploadFailed
? "Remove the failed attachment to send"
: "Send (⌘+Enter)"
showStop
? stopControl.stopping
? "Stopping…"
: stopScope === "subtree"
? "Stop and pause subtree"
: "Stop and pause task"
: queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: uploadPending
? "Waiting for upload to finish"
: uploadFailed
? "Remove the failed attachment to send"
: "Send (⌘+Enter)"
}
aria-label={
queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: "Send"
showStop
? stopControl.stopping
? "Stopping…"
: "Stop"
: queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: "Send"
}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center transition-transform hover:scale-105 disabled:scale-100",
@ -1226,15 +1253,24 @@ export function TaskChatComposer({
? "rounded-full bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "rounded-md bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
)}
data-testid="task-chat-composer-send"
data-testid={
showStop ? "task-chat-composer-stop" : "task-chat-composer-send"
}
>
{submitting ? (
{submitting || (showStop && stopControl.stopping) ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : showStop ? (
<Square className="h-4 w-4 fill-current" aria-hidden />
) : (
<ArrowUp className="h-4 w-4" aria-hidden />
)}
</button>
</div>
{stopControl.error ? (
<p role="alert" className="text-xs text-destructive">
{stopControl.error}
</p>
) : null}
</>
)}
</div>

View File

@ -42,7 +42,8 @@ describe("TaskChatMarker", () => {
kind: "marker",
variant: "interrupted",
label: "Run failed",
detail: "The runner stopped before returning an answer (runner_exited).",
detail:
"The runner stopped before returning an answer (runner_exited).",
collapsible: true,
createdAtIso: "2026-09-01T12:00:00.000Z",
runHref: "/agents/codex/runs/run-1",
@ -70,10 +71,39 @@ describe("TaskChatMarker", () => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("runner_exited");
expect(
container.querySelector('a[href="/agents/codex/runs/run-1"]')?.textContent,
container.querySelector('a[href="/agents/codex/runs/run-1"]')
?.textContent,
).toBe("View run");
});
it("keeps expected cancellation neutral when collapsed and expanded", () => {
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatMarker
item={{
id: "cancelled",
kind: "marker",
variant: "interrupted",
tone: "neutral",
label: "Run cancelled",
detail: "Cancelled by you.",
collapsible: true,
}}
/>
</ThemeProvider>,
),
);
const toggle = container.querySelector<HTMLButtonElement>(
"button[aria-expanded]",
)!;
expect(toggle.classList).toContain("text-muted-foreground");
expect(container.querySelector(".text-destructive")).toBeNull();
flushSync(() => toggle.click());
expect(container.textContent).toContain("Cancelled by you.");
expect(container.querySelector(".text-destructive")).toBeNull();
});
it("keeps Try again available without retry instructions in the detail", async () => {
const onTryAgain = vi.fn();
flushSync(() =>
@ -97,7 +127,9 @@ describe("TaskChatMarker", () => {
const retry = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-run-failed-try-again"]',
)!;
expect(container.textContent).not.toContain("You can retry this message now");
expect(container.textContent).not.toContain(
"You can retry this message now",
);
flushSync(() => retry.click());
await Promise.resolve();
expect(onTryAgain).toHaveBeenCalledTimes(1);

View File

@ -1,6 +1,6 @@
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, CircleDot, OctagonX, Flag } from "lucide-react";
import { ChevronDown, CircleDot, OctagonX, Square, Flag } from "lucide-react";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type { TaskChatMarkerItem } from "./task-chat-model";
import { Button } from "@/components/ui/button";
@ -30,8 +30,8 @@ export function TaskChatMarker({
const [open, setOpen] = useState(false);
const detailsId = useId();
const streamlined = useStreamlinedTaskChatPresentation();
const Icon = VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted";
const Icon = item.tone === "neutral" ? Square : VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted" && item.tone !== "neutral";
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
const handleTryAgain = () => {
void Promise.resolve()
@ -53,14 +53,17 @@ export function TaskChatMarker({
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5 text-destructive",
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5",
interrupted ? "text-destructive" : "text-muted-foreground",
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate font-medium">{item.label}</span>
{relative ? (
<span className="shrink-0 text-muted-foreground/70">· {relative}</span>
<span className="shrink-0 text-muted-foreground/70">
· {relative}
</span>
) : null}
<ChevronDown
className={cn(
@ -90,7 +93,9 @@ export function TaskChatMarker({
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
>
{item.detail ? (
<div className="px-3 py-2.5 text-foreground/90">{item.detail}</div>
<div className="px-3 py-2.5 text-foreground/90">
{item.detail}
</div>
) : null}
{item.runHref || onTryAgain ? (
<div className="flex items-center justify-end gap-2 border-t border-border/70 bg-background/50 px-3 py-2 dark:bg-background/30">
@ -124,11 +129,25 @@ export function TaskChatMarker({
role={streamlined ? "separator" : undefined}
aria-label={streamlined ? item.label : undefined}
>
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
<span className={cn("flex items-center gap-1.5", interrupted && "text-destructive")}>
<span
className={cn(
"h-px flex-1",
interrupted
? "border-t border-dashed border-destructive/50"
: "bg-border",
)}
/>
<span
className={cn(
"flex items-center gap-1.5",
interrupted && "text-destructive",
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="font-medium">{item.label}</span>
{item.detail ? <span className="text-muted-foreground">· {item.detail}</span> : null}
{item.detail ? (
<span className="text-muted-foreground">· {item.detail}</span>
) : null}
{onTryAgain ? (
<Button
type="button"
@ -142,7 +161,14 @@ export function TaskChatMarker({
</Button>
) : null}
</span>
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
<span
className={cn(
"h-px flex-1",
interrupted
? "border-t border-dashed border-destructive/50"
: "bg-border",
)}
/>
</div>
);
}

View File

@ -243,6 +243,8 @@ export interface TaskChatMarkerItem {
detail?: string;
/** Renders the marker as a quiet disclosure row with detail beneath it. */
collapsible?: boolean;
/** Expected cancellation is neutral; unexpected failures remain destructive. */
tone?: "neutral" | "error";
runId?: string;
createdAtIso?: string;
runHref?: string;

View File

@ -1046,6 +1046,7 @@ describe("LiveUpdatesProvider visible issue toast suppression", () => {
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
const queryClient = {
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) return { id: "run-1" };
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
@ -1399,3 +1400,30 @@ describe("dispatchLiveEventToSubscribers", () => {
expect(received).toEqual(["still-called"]);
});
});
describe("task subtree notification context", () => {
const root = { id: "root", companyId: "company", identifier: "PAP-204", assigneeAgentId: "parent-agent" };
const descendants = [{ id: "child", identifier: "PAP-205", assigneeAgentId: "child-agent", executionRunId: "child-run" }];
const queryClient = {
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-204"))) return root;
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.listByDescendantRoot("company", "root"))) return descendants;
},
};
it("suppresses descendant cancellation and activity on the visible subtree", () => {
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { runId: "child-run", agentId: "child-agent", status: "cancelled" }, { isForegrounded: true })).toBe(true);
expect(__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { entityType: "issue", entityId: "child" }, { isForegrounded: true })).toBe(true);
});
it("uses the event task when a terminal run has already left the cache", () => {
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { issueId: "root", runId: "finished-run" }, { isForegrounded: true })).toBe(true);
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { issueId: "unrelated", runId: "child-run", agentId: "child-agent" }, { isForegrounded: true })).toBe(false);
});
it("retains notifications for unrelated tasks and background pages", () => {
for (const [agentId, foregrounded] of [["other-agent", true], ["child-agent", true], ["child-agent", false]] as const) {
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { agentId, runId: "another-run", status: "cancelled" }, { isForegrounded: foregrounded })).toBe(false);
}
});
it("suppresses the run shown on its own run detail page", () => {
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/agents/alex/runs/child-run", { runId: "child-run" }, { isForegrounded: true })).toBe(true);
});
});

View File

@ -163,6 +163,9 @@ interface VisibleIssueRouteContext {
issueRefs: Set<string>;
assigneeAgentId: string | null;
runIds: Set<string>;
subtreeIssueRefs: Set<string>;
subtreeAgentIds: Set<string>;
subtreeRunIds: Set<string>;
}
function resolveIssueQueryRefs(
@ -265,11 +268,37 @@ function resolveVisibleIssueRouteContext(
if (run.runId) runIds.add(run.runId);
}
// Notifications about descendants belong to the subtree already on screen.
// Keep these separate from issueRefs, which also drives cache invalidation.
const subtreeIssueRefs = new Set(issueRefs);
const subtreeAgentIds = new Set<string>();
const subtreeRunIds = new Set(runIds);
if (issue?.companyId && issue.id) {
const descendants = queryClient.getQueryData<Issue[]>(
queryKeys.issues.listByDescendantRoot(issue.companyId, issue.id),
) ?? [];
for (const descendant of descendants) {
subtreeIssueRefs.add(descendant.id);
if (descendant.identifier) subtreeIssueRefs.add(descendant.identifier);
if (descendant.assigneeAgentId) subtreeAgentIds.add(descendant.assigneeAgentId);
if (descendant.executionRunId) subtreeRunIds.add(descendant.executionRunId);
}
}
if (issue?.companyId) {
const companyRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.liveRuns(issue.companyId)) ?? [];
for (const run of companyRuns) {
if (run.issueId && subtreeIssueRefs.has(run.issueId)) subtreeRunIds.add(run.id);
}
}
return {
routeIssueRef: issueRef,
issueRefs,
assigneeAgentId: issue?.assigneeAgentId ?? null,
runIds,
subtreeIssueRefs,
subtreeAgentIds,
subtreeRunIds,
};
}
@ -300,7 +329,7 @@ function shouldSuppressActivityToastForVisibleIssue(
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
return overlaps(context.subtreeIssueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
}
function shouldSuppressRunStatusToastForVisibleIssue(
@ -309,14 +338,19 @@ function shouldSuppressRunStatusToastForVisibleIssue(
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
if (!(options?.isForegrounded ?? isPageForegrounded())) return false;
const visibleRunId = toCompanyRelativePath(pathname).match(/^\/agents\/[^/]+\/runs\/([^/]+)/)?.[1];
if (visibleRunId && decodeURIComponent(visibleRunId) === readString(payload.runId)) return true;
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
const issueId = readString(payload.issueId);
if (issueId) return context.subtreeIssueRefs.has(issueId);
const runId = readString(payload.runId);
if (runId && context.runIds.has(runId)) return true;
if (runId) return context.subtreeRunIds.has(runId);
const agentId = readString(payload.agentId);
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
return !!agentId && (agentId === context.assigneeAgentId || context.subtreeAgentIds.has(agentId));
}
function invalidateVisibleIssueRunQueries(
@ -528,10 +562,10 @@ function shouldSuppressAgentStatusToastForVisibleIssue(
options?: VisibleRouteOptions,
): boolean {
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context?.assigneeAgentId) return false;
if (!context) return false;
const agentId = readString(payload.agentId);
return !!agentId && agentId === context.assigneeAgentId;
return !!agentId && (agentId === context.assigneeAgentId || context.subtreeAgentIds.has(agentId));
}
function shouldDeferIssueRefetchForVisibleAgentActivity(
@ -828,7 +862,7 @@ function buildRunStatusToast(
const error = readString(payload.error);
const triggerDetail = readString(payload.triggerDetail);
const name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`;
const tone = status === "succeeded" ? "success" : status === "cancelled" ? "warn" : "error";
const tone = status === "succeeded" ? "success" : status === "cancelled" ? "info" : "error";
const statusLabel =
status === "succeeded" ? "succeeded"
: status === "failed" ? "failed"
@ -1198,6 +1232,8 @@ function handleLiveEvent(
const nameOf = (id: string) => resolveAgentName(queryClient, expectedCompanyId, id);
const payload = event.payload ?? {};
// Resolve membership before terminal lifecycle patches remove live-run rows.
const suppressRunToast = event.type === "heartbeat.run.status" && shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload);
const liveStatusPatch = readRunLiveStatusPatchFromPayload(payload, event.createdAt, event.type);
if (liveStatusPatch) {
applyRunLiveStatusPatchToCaches(queryClient, expectedCompanyId, pathname, liveStatusPatch);
@ -1221,7 +1257,7 @@ function handleLiveEvent(
const toast = buildRunStatusToast(payload, nameOf);
if (
toast &&
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
!suppressRunToast
) {
gatedPushToast(gate, pushToast, "run-status", toast);
}

View File

@ -0,0 +1,27 @@
import { useRef, useState } from "react";
/** Keeps stop requests independent of draft submission and prevents double clicks. */
export function useComposerStop(onStop?: () => Promise<void>, pending = false) {
const inFlight = useRef(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
async function stop() {
if (!onStop || inFlight.current || pending) return;
inFlight.current = true;
setStopping(true);
setError(null);
try {
await onStop();
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to stop. Try again.",
);
} finally {
inFlight.current = false;
setStopping(false);
}
}
return { stop, stopping: stopping || pending, error };
}

View File

@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HeartbeatRun } from "@paperclipai/shared";
import { waitForStoppedRuns } from "./wait-for-stopped-runs";
function run(id: string, status: HeartbeatRun["status"]) {
return { id, status } as HeartbeatRun;
}
afterEach(() => vi.useRealTimers());
describe("stop confirmation", () => {
it("waits for native cancellation acknowledgment after the run becomes terminal", async () => {
vi.useFakeTimers();
const native = { ...run("native", "cancelled"), runtimeMode: "native" };
const getRun = vi
.fn()
.mockResolvedValueOnce({
...native,
resultJson: { nativeCancellation: { dispatchState: "pending" } },
})
.mockResolvedValueOnce({
...native,
resultJson: { nativeCancellation: { dispatchState: "acknowledged" } },
});
const finished = vi.fn();
const result = waitForStoppedRuns(["native"], { getRun }).then(finished);
await vi.advanceTimersByTimeAsync(0);
expect(finished).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
await result;
expect(finished).toHaveBeenCalledOnce();
});
it("waits for every affected run, including scheduled retries", async () => {
vi.useFakeTimers();
const getRun = vi
.fn()
.mockResolvedValueOnce(run("native", "running"))
.mockResolvedValueOnce(run("legacy", "scheduled_retry"))
.mockResolvedValueOnce(run("native", "cancelled"))
.mockResolvedValueOnce(run("legacy", "running"))
.mockResolvedValueOnce(run("legacy", "cancelled"));
const finished = vi.fn();
const result = waitForStoppedRuns(["native", "legacy", "native"], {
getRun,
}).then(finished);
await vi.advanceTimersByTimeAsync(500);
expect(finished).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
await result;
expect(finished).toHaveBeenCalledOnce();
expect(getRun).toHaveBeenCalledTimes(5);
});
it("does not claim termination when a paused run continues", async () => {
vi.useFakeTimers();
const result = waitForStoppedRuns(["active"], {
getRun: vi.fn().mockResolvedValue(run("active", "running")),
timeoutMs: 1000,
});
const assertion = expect(result).rejects.toThrow(
"pause was saved, but work is still stopping",
);
await vi.advanceTimersByTimeAsync(1000);
await assertion;
});
it("distinguishes a verification error from a failed pause", async () => {
await expect(
waitForStoppedRuns(["active"], {
getRun: vi.fn().mockRejectedValue(new Error("offline")),
}),
).rejects.toThrow("pause was saved, but stopping could not be verified");
});
});
it("bounds a hung status request instead of leaving Stop pending forever", async () => {
vi.useFakeTimers();
const result = waitForStoppedRuns(["active"], {
getRun: () => new Promise(() => {}),
timeoutMs: 1000,
});
const assertion = expect(result).rejects.toThrow(
"stopping could not be verified",
);
await vi.advanceTimersByTimeAsync(1000);
await assertion;
});

View File

@ -0,0 +1,61 @@
import { heartbeatsApi } from "../api/heartbeats";
const LIVE_STATUSES = new Set(["queued", "running", "scheduled_retry"]);
/** A tree hold response acknowledges the hold, not necessarily runner termination. */
export async function waitForStoppedRuns(
runIds: string[],
options: {
getRun?: typeof heartbeatsApi.get;
timeoutMs?: number;
intervalMs?: number;
} = {},
) {
const getRun = options.getRun ?? heartbeatsApi.get;
const deadline = Date.now() + (options.timeoutMs ?? 30_000);
let remaining = [...new Set(runIds)];
while (remaining.length > 0) {
let states;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
states = await Promise.race([
Promise.all(remaining.map((id) => getRun(id))),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error("Stop verification timed out")),
Math.max(0, deadline - Date.now()),
);
}),
]);
} catch {
throw new Error(
"The pause was saved, but stopping could not be verified. Refresh and try Stop again if work is still running.",
);
} finally {
clearTimeout(timeout);
}
remaining = states
.filter((run) => {
if (LIVE_STATUSES.has(run.status)) return true;
if (!("runtimeMode" in run) || run.runtimeMode !== "native" || run.status !== "cancelled")
return false;
const cancellation = run.resultJson?.nativeCancellation;
return (
!cancellation ||
typeof cancellation !== "object" ||
!("dispatchState" in cancellation) ||
cancellation.dispatchState !== "acknowledged"
);
})
.map((run) => run.id);
if (remaining.length === 0) return;
if (Date.now() >= deadline) {
throw new Error(
"The pause was saved, but work is still stopping. Try Stop again if it continues.",
);
}
await new Promise((resolve) =>
setTimeout(resolve, options.intervalMs ?? 500),
);
}
}

View File

@ -1,4 +1,7 @@
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 { useState } from "react";
import { ServicesList } from "./apps/app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
@ -448,6 +451,27 @@ function Swatch({ name, cssVar }: { name: string; cssVar: string }) {
/* Page */
/* ------------------------------------------------------------------ */
function TaskExecutionControlsExample() {
const [running, setRunning] = useState(true);
const [dialogMode, setDialogMode] = useState<"resume" | "cancel" | "restore" | null>(null);
const [wake, setWake] = useState(true);
return <div className="max-w-xl space-y-4">
<div className="w-52 rounded-md border border-border p-1">
<TaskTreeControlMenuItems scope="subtree" canPause={running} canResume={!running} canCancel canRestore={!running}
onPause={() => setRunning(false)} onResume={() => setDialogMode("resume")}
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} />
<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={() => {}}
onApply={() => { setRunning(dialogMode !== "cancel" && wake); setDialogMode(null); }} />
</div>;
}
export function DesignGuide() {
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("medium");
@ -519,6 +543,10 @@ export function DesignGuide() {
</div>
</Section>
<Section title="Task Execution Controls">
<TaskExecutionControlsExample />
</Section>
<Section title="Task Collection">
<p className="max-w-prose text-sm text-muted-foreground">
CollectionToolbar owns shared geometry while each page owns its state and behavior.

View File

@ -4014,7 +4014,7 @@ describe("IssueDetail", () => {
).toBe("blocked");
});
it("refreshes subtree pause state after resuming a hold", async () => {
it.each([false, true])("refreshes a released pause and shows partial wake failure=%s inline", async (wakeFailed) => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
@ -4067,7 +4067,7 @@ describe("IssueDetail", () => {
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockIssuesApi.releaseTreeHold.mockImplementation(() => {
activePauseHoldState = null;
return Promise.resolve(releasedHold);
return Promise.resolve({ ...releasedHold, ...(wakeFailed ? { wakeFailures: [{ issueId: "child-1", message: "Agent unavailable" }] } : {}) });
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
@ -4085,11 +4085,11 @@ describe("IssueDetail", () => {
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Subtree pause is active.");
expect(container.textContent).toContain("Subtree is paused.");
});
const pauseBannerTitle = Array.from(container.querySelectorAll("span")).find(
(element) => element.textContent?.trim() === "Subtree pause is active.",
(element) => element.textContent?.trim() === "Subtree is paused.",
);
expect(pauseBannerTitle?.closest(".rounded-md")?.classList).toContain(
"mt-3",
@ -4114,7 +4114,7 @@ describe("IssueDetail", () => {
.filter((button) => button.textContent?.trim() === "Resume subtree")
.at(-1);
expect(applyResumeButton).toBeTruthy();
expect(container.textContent).toContain("CodexCoder");
expect(container.textContent).toContain("Wake affected agents (1)");
await act(async () => {
applyResumeButton!.click();
@ -4133,18 +4133,20 @@ describe("IssueDetail", () => {
expect(
mockIssuesApi.getTreeControlState.mock.calls.length,
).toBeGreaterThanOrEqual(2);
expect(mockPushToast).toHaveBeenCalledWith(
expect(mockPushToast).not.toHaveBeenCalledWith(
expect.objectContaining({
title: "Subtree resumed",
body: "Ready to continue",
}),
);
await waitForAssertion(() => {
expect(container.textContent).not.toContain("Subtree pause is active.");
expect(container.textContent).not.toContain("Subtree is paused.");
});
if (wakeFailed) expect(container.querySelector('[role="alert"]')?.textContent).toContain("Pause released");
else expect(container.querySelector('[role="alert"]')).toBeNull();
});
it("uses simplified full-subtree pause controls", async () => {
it("pauses the subtree immediately without preview or confirmation", async () => {
mockIssuesApi.previewTreeControl.mockClear();
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
@ -4202,7 +4204,7 @@ describe("IssueDetail", () => {
const pauseMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Pause subtree...");
).find((button) => button.textContent?.trim() === "Pause subtree");
expect(pauseMenuButton).toBeTruthy();
await act(async () => {
@ -4211,28 +4213,8 @@ describe("IssueDetail", () => {
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
releasePolicy: { strategy: "manual" },
});
expect(container.textContent).not.toContain("Pause mode");
expect(container.textContent).not.toContain("Release policy");
expect(container.textContent).not.toContain("Status breakdown");
expect(container.textContent).not.toContain("Active runs cancelled");
expect(container.textContent).toContain("Paused child");
expect(container.textContent).toContain("Completed child");
expect(container.textContent).toContain("Complete");
const pauseApplyButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Pause and stop work");
expect(pauseApplyButton).toBeTruthy();
await act(async () => {
pauseApplyButton!.click();
});
await flushReact();
expect(mockIssuesApi.previewTreeControl).not.toHaveBeenCalled();
expect(container.querySelector('[data-slot="dialog-content"]')).toBeNull();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: null,
@ -4240,7 +4222,7 @@ describe("IssueDetail", () => {
});
});
it("exposes leaf pause controls and routes issue active-run stop through Pause work", async () => {
it.each(["active-run", "composer"])("routes %s Stop and the menu through the same pause operation", async (control) => {
const pausePreview = createPausePreview();
pausePreview.totals = {
...pausePreview.totals,
@ -4254,7 +4236,7 @@ describe("IssueDetail", () => {
const pauseHold = createPauseHold({
id: "leaf-pause-hold-1",
mode: "pause",
reason: "Paused from active run controls.",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
members: [],
});
@ -4272,6 +4254,10 @@ describe("IssueDetail", () => {
preview: pausePreview,
});
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([{
id: "run-active-1", agentId: "agent-1", status: "running", runtimeMode: "legacy",
issueId: "issue-1", adapterType: "process",
}]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
@ -4295,17 +4281,21 @@ describe("IssueDetail", () => {
const chatPauseButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Pause work");
).filter((button) => button.textContent?.trim() === "Pause work").at(-1);
expect(chatPauseButton).toBeTruthy();
await act(async () => {
chatPauseButton!.click();
if (control === "composer") {
const stop = mockIssueChatThreadRender.mock.calls.at(-1)?.[0].onCancelRun;
expect(stop).toBeTypeOf("function");
await stop();
} else chatPauseButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: "Paused from active run controls.",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
metadata: { source: "issue_active_run_control", runId: "run-active-1" },
});
@ -4323,8 +4313,22 @@ describe("IssueDetail", () => {
const pauseMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Pause work...");
).find((button) => button.textContent?.trim() === "Pause work");
expect(pauseMenuButton).toBeTruthy();
await act(async () => { pauseMenuButton!.click(); });
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenLastCalledWith("PAP-1", {
mode: "pause", reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
});
expect(mockPushToast).not.toHaveBeenCalled();
mockIssuesApi.createTreeHold.mockRejectedValueOnce(new Error("Unable to pause. Try again."));
await act(async () => {
await mockIssueChatThreadRender.mock.calls.at(-1)?.[0].onStopRun("run-active-1");
});
await flushReact();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Unable to pause. Try again.");
expect(mockPushToast).not.toHaveBeenCalled();
});
it("routes live-run finalization actions through run cancellation before issue status update", async () => {
@ -4870,7 +4874,7 @@ describe("IssueDetail", () => {
localStorage.removeItem("paperclip:issue-comment-draft:issue-1");
});
it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => {
it("renders a quiet task pause notice and defaults leaf resume to wake the assignee", async () => {
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
status: "released",
@ -4918,9 +4922,9 @@ describe("IssueDetail", () => {
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Paused by board.");
expect(container.textContent).toContain("Task is paused.");
expect(container.textContent).toContain("in_review");
expect(container.textContent).not.toContain("Subtree pause is active.");
expect(container.textContent).not.toContain("Subtree is paused.");
});
const resumeButton = Array.from(container.querySelectorAll("button")).find(
@ -5044,14 +5048,11 @@ describe("IssueDetail", () => {
mode: "restore",
releasePolicy: { strategy: "manual" },
});
expect(container.textContent).toContain(
"Restore tasks cancelled by this subtree operation so work can resume.",
);
expect(container.textContent).toContain("Cancelled child");
expect(container.textContent).toContain("1 task will be restored.");
const restoreApplyButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Restore 1 tasks");
).find((button) => button.textContent?.trim() === "Restore 1 task");
expect(restoreApplyButton).toBeTruthy();
await act(async () => {
@ -5067,7 +5068,8 @@ describe("IssueDetail", () => {
});
});
it("bounds the subtree control dialog with an internal scroll body", async () => {
it("confirms cancellation once without a reason, checkbox, or task inventory", async () => {
mockIssuesApi.createTreeHold.mockClear();
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
@ -5099,6 +5101,9 @@ describe("IssueDetail", () => {
await flushReact();
await flushReact();
const moreButton = container.querySelector('button[aria-label="More task actions"]')!;
await act(async () => { moreButton.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); });
await flushReact();
const cancelMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Cancel subtree...");
@ -5119,45 +5124,21 @@ describe("IssueDetail", () => {
'[data-slot="dialog-content"]',
) as HTMLDivElement | null;
expect(dialogContent).toBeTruthy();
expect(dialogContent!.className).toContain("max-h-(--sz-calc-18)");
expect(dialogContent!.className).toContain("overflow-hidden");
expect(dialogContent!.className).toContain("flex-col");
const bodyScrollRegion = Array.from(
dialogContent!.querySelectorAll("div"),
).find(
(element) =>
typeof element.className === "string" &&
element.className.includes("overflow-y-auto") &&
element.textContent?.includes("Reason (optional)"),
);
expect(bodyScrollRegion?.className).toContain("min-h-0");
expect(bodyScrollRegion?.className).toContain("overscroll-contain");
const cancelApplyButton = Array.from(
dialogContent!.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Cancel 24 tasks") as
HTMLButtonElement | undefined;
expect(cancelApplyButton).toBeTruthy();
expect(cancelApplyButton!.disabled).toBe(true);
const confirmationCheckbox = dialogContent!.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement | null;
expect(confirmationCheckbox).toBeTruthy();
await act(async () => {
confirmationCheckbox!.click();
});
expect(dialogContent!.textContent).toContain("Cancel subtree?");
expect(dialogContent!.textContent).toContain("24 tasks will be cancelled.");
expect(dialogContent!.textContent).toContain("Keep tasks");
expect(dialogContent!.querySelector('textarea, input[type="checkbox"]')).toBeNull();
expect(dialogContent!.textContent).not.toContain("Cancellable child");
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
const cancelApplyButton = Array.from(dialogContent!.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Cancel 24 tasks")!;
expect(cancelApplyButton.disabled).toBe(false);
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: { ...createPauseHold(), mode: "cancel" }, preview: createCancelPreview(24) });
await act(async () => { cancelApplyButton.click(); });
await flushReact();
expect(cancelApplyButton!.disabled).toBe(false);
const footer = Array.from(dialogContent!.querySelectorAll("div")).find(
(element) =>
typeof element.className === "string" &&
element.className.includes("border-t") &&
element.textContent?.includes("Close"),
);
expect(footer?.className).toContain("bg-background");
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "cancel", reason: null, releasePolicy: { strategy: "manual" },
});
});
it("keeps the authoritative Paperclip queue mounted after handoff promotion", async () => {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,455 @@
import { QueryClient } from "@tanstack/react-query";
import { __liveUpdatesTestUtils } from "@/context/LiveUpdatesProvider";
import { useToastActions } from "@/context/ToastContext";
import { ToastViewport } from "@/components/ToastViewport";
import { queryKeys } from "@/lib/queryKeys";
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
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";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { saveDraft, clearDraft } from "@/lib/composer-draft";
type ExampleProps = {
initialState?: "running" | "idle" | "paused" | "stopping";
draft?: string;
parent?: boolean;
menuOpen?: boolean;
confirmation?: boolean;
stopFails?: boolean;
cancelFails?: boolean;
previewLoading?: boolean;
applying?: boolean;
mobile?: boolean;
resumeBlocked?: boolean;
wakeFails?: boolean;
};
function TaskExecutionExample({
initialState = "running",
draft = "",
parent = true,
menuOpen = false,
confirmation = false,
stopFails = false,
cancelFails = false,
previewLoading = false,
applying = false,
mobile = false,
resumeBlocked = false,
wakeFails = false,
}: ExampleProps) {
const { pushToast } = useToastActions();
const [notificationCache] = useState(() => {
const cache = new QueryClient();
cache.setQueryData(queryKeys.issues.detail("PAP-204"), {
id: "task-parent",
identifier: "PAP-204",
companyId: "demo",
assigneeAgentId: "alex",
});
cache.setQueryData(queryKeys.issues.activeRun("PAP-204"), {
id: "alex-run",
});
cache.setQueryData(
queryKeys.issues.listByDescendantRoot("demo", "task-parent"),
[
{
id: "task-child",
assigneeAgentId: "child",
executionRunId: "child-run",
},
],
);
return cache;
});
function notifyRunCancelled(agentId: string) {
const payload = {
runId: `${agentId}-run`,
agentId,
status: "cancelled",
error: "Cancelled by control plane",
};
if (
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
notificationCache,
"/PAP/issues/PAP-204",
payload,
{ isForegrounded: true },
)
)
return;
const toast = __liveUpdatesTestUtils.buildRunStatusToast(
payload,
() => "Other task",
);
if (toast) pushToast(toast);
}
const [state, setState] = useState(initialState);
const [menu, setMenu] = useState(menuOpen);
const [dialog, setDialog] = useState(confirmation);
const [mode, setMode] = useState<"resume" | "cancel" | "restore">(
resumeBlocked || wakeFails ? "resume" : "cancel",
);
const [wake, setWake] = useState(true);
const [pending, setPending] = useState(applying);
const [cancelled, setCancelled] = useState(false);
const [error, setError] = useState<string | null>(null);
const [messages, setMessages] = useState<string[]>([]);
const [draftKey] = useState(() => {
const key = "paperclip:storybook:composer-stop";
clearDraft(key);
if (draft) saveDraft(key, draft);
return key;
});
const scope = parent ? "subtree" : "leaf";
async function pause() {
setMenu(false);
setState("stopping");
await new Promise((resolve) => setTimeout(resolve, 750));
if (stopFails) {
setState("running");
throw new Error("Unable to stop. Try again.");
}
notifyRunCancelled("alex");
if (parent) notifyRunCancelled("child");
setState("paused");
}
function openDialog(next: typeof mode) {
setMode(next);
setError(null);
setMenu(false);
setDialog(true);
}
async function apply() {
setPending(true);
await new Promise((resolve) => setTimeout(resolve, 750));
setPending(false);
if (cancelFails && mode === "cancel") {
setError("Unable to cancel tasks. Try again.");
return;
}
if (mode === "resume" && resumeBlocked && wake) {
setError(
"Cannot wake this task until its stopped execution is reconciled. Resume without waking agents, or review the stopped run first.",
);
return;
}
setDialog(false);
if (mode === "resume" && wakeFails && wake)
setError(
"Pause released, but 1 task could not start. Agent unavailable. Check the affected agent and try starting it again.",
);
if (mode === "cancel") {
setCancelled(true);
setState("idle");
} else {
setCancelled(false);
setState(wake && !wakeFails ? "running" : "idle");
}
}
return (
<div className={mobile ? "mx-auto max-w-sm" : "mx-auto max-w-3xl"}>
<div className="flex items-center justify-between gap-3 border-b border-border py-3">
<span className="font-mono text-xs text-muted-foreground">PAP-204</span>
<Popover open={menu} onOpenChange={setMenu}>
<PopoverTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
aria-label="More task actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-52 p-1">
<TaskTreeControlMenuItems
scope={scope}
canPause={state !== "paused" && !cancelled}
canResume={state === "paused"}
canCancel={parent && !cancelled}
canRestore={parent && cancelled}
pending={state === "stopping" || pending}
onPause={() => {
void pause().catch((err: Error) => setError(err.message));
}}
onResume={() => openDialog("resume")}
onCancel={() => openDialog("cancel")}
onRestore={() => openDialog("restore")}
/>
</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">
Polish the task conversation
</h1>
<p className="text-sm text-muted-foreground">
Make it easy to send a follow-up or pause work.
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Bot className="h-4 w-4" />
<span className="font-medium">Alex</span>
<span role="status" className="text-muted-foreground">
{cancelled
? "Cancelled"
: state === "running"
? "Working"
: state === "stopping"
? "Stopping…"
: state === "paused"
? "Paused"
: "Ready"}
</span>
</div>
<p className="text-sm">
Im checking the composer and the task controls. Next Ill verify the
interaction on mobile.
</p>
{parent ? (
<div className="space-y-2 text-sm text-muted-foreground">
<div>Review composer behavior</div>
<div>Verify mobile layout</div>
</div>
) : null}
{state === "paused" || cancelled ? (
<TaskChatMarker
item={{
id: "stopped-run",
kind: "marker",
variant: "interrupted",
tone: "neutral",
label: "Run cancelled",
detail: "The run was cancelled before returning an answer.",
collapsible: true,
runHref: "/agents/alex/runs/stopped-run",
}}
/>
) : null}
{messages.map((message, index) => (
<div key={index} className="rounded-md bg-muted p-3 text-sm">
<span className="text-xs text-muted-foreground">
{state === "running" || state === "stopping" || state === "paused"
? "Queued"
: "Sent"}
</span>
<p>{message}</p>
</div>
))}
<TaskChatComposer
draftKey={draftKey}
workMode="standard"
mobile={mobile}
onStop={
state === "running" || state === "stopping" ? pause : undefined
}
stopPending={state === "stopping"}
stopScope={scope}
onAdd={async (body) => {
setMessages((current) => [...current, body]);
}}
onAttachImage={async (file) => ({
id: "attachment-story",
companyId: "company-storybook",
issueId: "issue-story",
issueCommentId: null,
assetId: "asset-story",
provider: "local",
objectKey: "storybook-attachment.txt",
sha256: "storybook",
createdByAgentId: null,
createdByUserId: "board",
updatedAt: new Date(),
contentPath: "/storybook-attachment.txt",
originalFilename: file.name,
contentType: file.type,
byteSize: file.size,
createdAt: new Date(),
})}
/>
{error && !dialog ? (
<p
role="alert"
className={
wakeFails
? "text-sm text-muted-foreground"
: "text-sm text-destructive"
}
>
{error}
</p>
) : null}
</div>
<ToastViewport />
<TaskTreeControlDialog
open={dialog}
onOpenChange={setDialog}
mode={mode}
scope={scope}
affectedCount={parent ? 3 : 1}
affectedAgentCount={parent ? 2 : 1}
loading={previewLoading}
error={error}
pending={pending}
valid={!previewLoading}
wakeAgents={wake}
onWakeAgentsChange={(wake) => { setError(null); setWake(wake); }}
onRetry={() => setError(null)}
onApply={() => {
void apply();
}}
/>
</div>
);
}
const meta = {
title: "Tasks/Execution Controls",
component: TaskExecutionExample,
parameters: { layout: "padded" },
} satisfies Meta<typeof TaskExecutionExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const RunningEmpty: Story = {};
export const RunningDraft: Story = {
args: { draft: "Please check the keyboard interaction too." },
};
export const Idle: Story = { args: { initialState: "idle" } };
export const Stopping: Story = { args: { initialState: "stopping" } };
export const Paused: Story = { args: { initialState: "paused" } };
export const StopFailure: Story = { args: { stopFails: true } };
export const LeafTask: Story = { args: { parent: false } };
export const KebabMenu: Story = { args: { menuOpen: true } };
export const CancelConfirmation: Story = { args: { confirmation: true } };
export const CancelLoading: Story = {
args: { confirmation: true, previewLoading: true },
};
export const Cancelling: Story = {
args: { confirmation: true, applying: true },
};
export const CancelFailure: Story = {
args: { confirmation: true, cancelFails: true },
};
export const Mobile: Story = {
args: { mobile: true },
globals: { viewport: { value: "mobile1", isRotated: false } },
};
export const Light: Story = { globals: { theme: "light" } };
export const AttachmentOnly: Story = {
play: async ({ canvasElement }) => {
const input =
canvasElement.querySelector<HTMLInputElement>('input[type="file"]')!;
await userEvent.upload(
input,
new File(["Acceptance notes"], "notes.txt", { type: "text/plain" }),
);
await expect(
within(canvasElement).getByRole("button", { name: "Send" }),
).toBeEnabled();
await expect(
within(canvasElement).queryByRole("button", { name: "Stop" }),
).toBeNull();
},
};
export const TypeAndClear: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: "Stop" })).toBeEnabled();
const editor = canvasElement.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
await userEvent.type(editor, "Please check mobile too.");
await expect(canvas.getByRole("button", { name: "Send" })).toBeEnabled();
await userEvent.clear(editor);
await expect(canvas.getByRole("button", { name: "Stop" })).toBeEnabled();
},
};
export const PausedLight: Story = {
args: { initialState: "paused" },
globals: { theme: "light" },
};
export const PausedMobile: Story = {
args: { initialState: "paused", mobile: true },
globals: { viewport: { value: "mobile1", isRotated: false } },
};
export const CancelledRunExpanded: Story = {
args: { initialState: "paused" },
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Run cancelled" }),
);
await expect(
within(canvasElement).getByText(
"The run was cancelled before returning an answer.",
),
).toBeVisible();
},
};
export const StopWithoutToasts: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Stop" }));
await expect(await canvas.findByText("Subtree is paused.")).toBeVisible();
await expect(
canvas.getByRole("button", { name: "Resume subtree" }),
).toBeVisible();
await expect(
canvas.getByRole("button", { name: "Run cancelled" }),
).toHaveClass("text-muted-foreground");
await expect(
canvas.queryByRole("button", { name: "Dismiss notification" }),
).toBeNull();
},
};
export const ResumeNeedsReview: Story = {
args: { initialState: "paused", confirmation: true, resumeBlocked: true },
play: async () => {
const page = within(document.body);
await userEvent.click(
within(page.getByRole("dialog")).getByRole("button", {
name: "Resume subtree",
}),
);
await expect(await page.findByRole("alert")).toHaveTextContent(
"stopped execution is reconciled",
);
},
};
export const ResumeWakeFailure: Story = {
args: { initialState: "paused", confirmation: true, wakeFails: true },
play: async () => {
const page = within(document.body);
await userEvent.click(
within(page.getByRole("dialog")).getByRole("button", {
name: "Resume subtree",
}),
);
await expect(await page.findByRole("alert")).toHaveTextContent(
"Pause released",
);
},
};