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:
parent
622376e995
commit
8cfd30fb07
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -1220,6 +1220,7 @@ export type {
|
|||
IssueLabel,
|
||||
IssueTreeControlPreview,
|
||||
IssueTreeHold,
|
||||
ReleaseIssueTreeHoldResponse,
|
||||
IssueTreeHoldMember,
|
||||
IssueTreeHoldReleasePolicy,
|
||||
IssueTreePreviewAgent,
|
||||
|
|
|
|||
|
|
@ -788,6 +788,7 @@ export type {
|
|||
export type {
|
||||
IssueTreeControlPreview,
|
||||
IssueTreeHold,
|
||||
ReleaseIssueTreeHoldResponse,
|
||||
IssueTreeHoldMember,
|
||||
IssueTreeHoldReleasePolicy,
|
||||
IssueTreePreviewAgent,
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 } : {}) });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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`, {}),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">
|
||||
I’m checking the composer and the task controls. Next I’ll 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",
|
||||
);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue