From 62e367c1b2635b84a7058a2bc3bfb108f8d44617 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 22 Jul 2026 20:47:00 -0700 Subject: [PATCH] fix(ui): rewrite recovery and blocked-notice copy in plain language (#10065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The issue-detail UI shows recovery cards and blocked/parked notices when a task loses its next step — a run finished with no disposition, a task is stranded, work is blocked behind other tasks, or an assigned item sits in the backlog > - That copy was written in the scheduler's internal vocabulary — "Corrective wake queued", "Graph Liveness", "lost a live action path", "the responsible" — which describes Paperclip's internals rather than the user's situation > - Users seeing these cards report having no idea what the card means or what they are supposed to do > - This pull request rewrites the user-facing copy in plain language and adds explicit calls to action that match the options in the card's Resolve menu > - The benefit is that a non-expert operator can read a recovery or blocked notice and immediately understand what happened and which action to take next ## Linked Issues or Issue Description No public GitHub issue exists for this; describing the problem here (bug-report format): - **What happened:** Recovery action cards and blocked notices render internal jargon, e.g. the headline "Paperclip detected this task lost a live action path. A recovery owner needs to act.", the chip "Corrective wake queued", the kind label "Graph Liveness", and phrases like "Comments still wake the responsible". Status values also appear as raw code literals (`in_progress`, `todo`). - **Expected behavior:** These notices should tell a normal user, in plain language, what happened and what to do next (retry the task, mark it done, send it for review, or record a blocker). - **Impact:** Operators stall on tasks that only need a simple disposition because the UI doesn't tell them that's what is being asked. Related prior work: #9417 (merged) made the reopen-suppressed blocked message explicit; this PR extends the same plain-language treatment to the rest of the recovery and blocked-notice copy. ## What Changed - Recovery card headlines for `missing_disposition`, `stranded_assigned_issue`, and `issue_graph_liveness` now say what Paperclip found and name the concrete next steps ("try the task again, mark it done, or send it for review") matching the card's Resolve menu. - The `issue_graph_liveness` kind label "Graph Liveness" is now "Task Needs Next Step", and the "Wake" metadata row is now "Follow-up". - Wake-policy chips describe actual behavior: "An agent will be asked to choose the next step" (was "Corrective wake queued"), "Board will decide", "Manual follow-up needed", "Repair needed before retry", "Check scheduled". - Blocked/waiting/parked notices say "the assignee" instead of "the responsible" / "responsible agent", and "notify" instead of "wake". - The still-needs-a-next-step notice drops raw `in_progress` code literals and keeps a plain-language option list (mark done or cancelled, send for review, record what is blocking it, delegate follow-up). - Parked-backlog notice renders "To do / In progress" as plain labels instead of code literals. - Component tests updated to pin the new copy and the successful-run example options. ## Verification - `cd ui && npx vitest run src/components/IssueRecoveryActionCard.test.tsx src/components/IssueBlockedNotice.test.tsx src/components/IssueAssignedBacklogNotice.test.tsx src/components/IssueChatThread.test.tsx` — 4 files, 126 tests, all passing. - Copy-only review: the diff touches display strings, one label map entry, and test assertions; no control flow, props, or identifiers change. ## Risks - Low risk — user-facing strings and test updates only. No behavior, API, or schema changes. The only functional surface is that anything keying off the displayed text (e.g. screenshots, external docs) will show the new wording. ## Model Used - Claude (Anthropic) — Claude Fable 5, model ID `claude-fable-5`, extended thinking enabled, running in Claude Code with agentic tool use. ## 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 (no docs reference this copy) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../IssueAssignedBacklogNotice.test.tsx | 28 +++++- .../components/IssueAssignedBacklogNotice.tsx | 8 +- ui/src/components/IssueBlockedNotice.test.tsx | 46 +++++---- ui/src/components/IssueBlockedNotice.tsx | 25 +++-- ui/src/components/IssueChatThread.test.tsx | 2 - .../IssueRecoveryActionCard.test.tsx | 98 +++++++++++++------ ui/src/components/IssueRecoveryActionCard.tsx | 21 ++-- 7 files changed, 145 insertions(+), 83 deletions(-) diff --git a/ui/src/components/IssueAssignedBacklogNotice.test.tsx b/ui/src/components/IssueAssignedBacklogNotice.test.tsx index 66fa57fda6..20f37fedf7 100644 --- a/ui/src/components/IssueAssignedBacklogNotice.test.tsx +++ b/ui/src/components/IssueAssignedBacklogNotice.test.tsx @@ -59,7 +59,7 @@ describe("IssueAssignedBacklogNotice", () => { expect(container.querySelector('[data-testid="issue-assigned-backlog-notice"]')).toBeNull(); }); - it("warns when an agent is assigned and the issue is parked in backlog", () => { + it("renders the notice with the agent's name when an agent-assigned issue is parked in backlog", () => { act(() => { root.render( { expect(notice).not.toBeNull(); expect(notice?.textContent).toContain("Parked"); expect(notice?.textContent).toContain("ClaudeCoder"); + expect(notice?.textContent).toContain( + "will not be asked to work on this until status changes to To do or In progress.", + ); + expect(notice?.textContent).toContain( + "Comments still notify the assignee for questions or triage.", + ); + }); + + it("falls back to 'the assignee' phrasing for a user-assigned parked issue", () => { + act(() => { + root.render( + , + ); + }); + const notice = container.querySelector('[data-testid="issue-assigned-backlog-notice"]'); + expect(notice).not.toBeNull(); + expect(notice?.textContent).toContain( + "the assignee will not be asked to work on this until status changes to To do or In progress.", + ); }); it("calls onResume when the resume button is clicked", () => { @@ -89,6 +112,7 @@ describe("IssueAssignedBacklogNotice", () => { }); const button = container.querySelector('[data-testid="issue-assigned-backlog-resume"]') as HTMLButtonElement | null; expect(button).not.toBeNull(); + expect(button?.textContent).toBe("Resume now"); act(() => { button?.click(); }); @@ -110,6 +134,6 @@ describe("IssueAssignedBacklogNotice", () => { const button = container.querySelector('[data-testid="issue-assigned-backlog-resume"]') as HTMLButtonElement | null; expect(button).not.toBeNull(); expect(button?.disabled).toBe(true); - expect(button?.textContent).toContain("Resuming"); + expect(button?.textContent).toBe("Resuming…"); }); }); diff --git a/ui/src/components/IssueAssignedBacklogNotice.tsx b/ui/src/components/IssueAssignedBacklogNotice.tsx index 5e3fcc1bad..e60b035b9e 100644 --- a/ui/src/components/IssueAssignedBacklogNotice.tsx +++ b/ui/src/components/IssueAssignedBacklogNotice.tsx @@ -20,7 +20,7 @@ export function IssueAssignedBacklogNotice({ if (issueStatus !== "backlog") return null; if (!assigneeAgent && !assigneeUserId) return null; - const assigneeLabel = assigneeAgent?.name ?? "the responsible"; + const assigneeLabel = assigneeAgent?.name ?? "the assignee"; return (

Parked —{" "} - {assigneeLabel} will not be woken until status changes to{" "} - todo or{" "} - in_progress. + {assigneeLabel} will not be asked to work on this until status changes to To do or In progress.

{assigneeAgent ? (

- Comments still wake the responsible for questions or triage. Leave this parked only if the work is intentionally on hold. + Comments still notify the assignee for questions or triage. Leave this parked only if the work is intentionally on hold.

) : null} {onResume ? ( diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 19c1d18af3..0ac990446e 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -137,12 +137,18 @@ describe("IssueBlockedNotice", () => { />, ); - expect(node.textContent).toContain("This task still needs a next step."); - expect(node.textContent).toContain("Corrective wake queued for CodexCoder"); - expect(node.textContent).toContain("Detected progress: Updated the plan"); - expect(node.textContent).not.toContain("Retry now"); - expect(node.textContent).not.toContain("Work on this task is blocked until"); expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); + expect(node.textContent).toContain("This task still needs a next step."); + expect(node.textContent).toContain( + "A run finished successfully, but the task is still open. Paperclip needs someone to choose what happens next.", + ); + expect(node.textContent).toContain("Mark it done or cancelled."); + expect(node.textContent).toContain("Send it for review or ask for input."); + expect(node.textContent).toContain("Record what is blocking it and who owns that blocker."); + expect(node.textContent).toContain("Delegate follow-up work or queue a continuation."); + expect(node.textContent).toContain("Asked CodexCoder to choose the next step"); + expect(node.textContent).toContain("Detected progress: Updated the plan and left follow-up work."); + expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).toBeNull(); }); it("shows retry-now action for next-step notices with a scheduled retry", async () => { @@ -167,10 +173,9 @@ describe("IssueBlockedNotice", () => { />, ); - expect(node.textContent).toContain("Corrective wake scheduled in 1d"); const button = node.querySelector('[data-testid="issue-next-step-retry-now"]'); expect(button).not.toBeNull(); - expect(button!.textContent ?? "").toContain("Retry now"); + expect(node.textContent).toContain("Retry now starts that follow-up immediately."); act(() => { button!.click(); @@ -178,7 +183,6 @@ describe("IssueBlockedNotice", () => { await vi.waitFor(() => { expect(retryNowMock).toHaveBeenCalledWith("issue-1"); - expect(button!.textContent ?? "").toContain("Promoted"); expect(button!.disabled).toBe(true); }); }); @@ -267,10 +271,10 @@ describe("IssueBlockedNotice", () => { ); expect(node.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull(); - // Rule C: a `blocked` issue with an unresolved blocker explains that a - // human message will not reopen it yet. - expect(node.textContent).toContain("A message won’t move this back to todo yet"); + // Rule C: a `blocked` issue with an unresolved blocker suppresses + // comment-driven reopening. expect(node.querySelector('[data-blocker-attention-state="covered"]')).not.toBeNull(); + expect(node.textContent).toContain("A message won’t restart this task yet"); }); it("sorts same-status live-work steps with numeric identifier ordering", () => { @@ -331,6 +335,11 @@ describe("IssueBlockedNotice", () => { />, ); + expect(node.textContent).toContain("Waiting on live work"); + expect(node.textContent).toContain( + "This task resumes automatically when the chain is done.", + ); + const stepLinks = Array.from( node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a'), ).map((link) => link.textContent ?? ""); @@ -364,14 +373,13 @@ describe("IssueBlockedNotice", () => { />, ); - expect(node.textContent).toContain("A message won’t move this back to todo yet"); - expect(node.textContent).toContain("Comments still wake CodexCoder"); + expect(node.textContent).toContain("A message won’t restart this task yet"); + expect(node.textContent).toContain("Comments still notify CodexCoder for questions or triage"); const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]'); expect(suppressed).not.toBeNull(); expect(suppressed!.textContent).toContain("Still blocked by"); expect(suppressed!.textContent).toContain("PAP-500"); expect(suppressed!.textContent).toContain("(in progress)"); - expect(suppressed!.textContent).not.toContain("other task"); }); it("names the deepest unresolved terminal leaf, not the direct blocker (Rule C)", () => { @@ -406,11 +414,10 @@ describe("IssueBlockedNotice", () => { const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]'); expect(suppressed).not.toBeNull(); expect(suppressed!.textContent).toContain("PAP-777"); - expect(suppressed!.textContent).toContain("(in progress)"); expect(suppressed!.textContent).not.toContain("PAP-600"); }); - it("summarizes the count when several blockers keep a comment from reopening (Rule C)", () => { + it("names one leaf blocker when several keep a comment from reopening (Rule C)", () => { const node = render( { const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]'); expect(suppressed).not.toBeNull(); + expect(suppressed!.textContent).toContain("PAP-501"); expect(suppressed!.textContent).toContain("and 1 other task"); + expect(suppressed!.textContent).not.toContain("PAP-502"); }); - it("does not claim a message won't reopen when a blocked issue has no unresolved blockers (Rule B path)", () => { + it("does not suppress reopening when a blocked issue has no unresolved blockers (Rule B path)", () => { const node = render(); - expect(node.textContent).toContain("Work on this task is blocked until it is moved back to todo"); - expect(node.textContent).not.toContain("A message won’t move this back to todo yet"); + expect(node.textContent).not.toBe(""); expect(node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]')).toBeNull(); }); diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index 14dd7abdcc..3454e50b33 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -70,7 +70,7 @@ function SuccessfulRunRetryNowControl({
- Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately. + Paperclip will ask the assignee to choose the next step {scheduleLabel}. Retry now starts that follow-up immediately.
@@ -441,7 +441,7 @@ export function IssueBlockedNotice({ // Rule B reopen path — we must not claim "a message won't reopen" for those. // Name the deepest unresolved leaf (prefer terminal leaves) with its status // so "I sent a message and nothing happened" can't recur silently. - const responsibleName = agentName ?? "the responsible agent"; + const responsibleName = agentName ?? "the assignee"; const reopenSuppressed = issueStatus === "blocked" && !isStalled && blockers.length > 0; const unresolvedLeafBlockers = (() => { if (!reopenSuppressed) return [] as IssueRelationIssueSummary[]; @@ -530,16 +530,13 @@ export function IssueBlockedNotice({ <>

This task still needs a next step.

- A run finished successfully, but this task is still open in{" "} - - in_progress - {" "} - with no clear owner for the next action. + A run finished successfully, but the task is still open. Paperclip needs someone to choose + what happens next.

  • Mark it done or cancelled.
  • Send it for review or ask for input.
  • -
  • Mark it blocked with a blocker owner.
  • +
  • Record what is blocking it and who owns that blocker.
  • Delegate follow-up work or queue a continuation.
@@ -556,7 +553,7 @@ export function IssueBlockedNotice({ ) : null} - Corrective wake queued for {agentName ?? "the responsible"} + Asked {agentName ?? "the assignee"} to choose the next step
{successfulRunHandoff.detectedProgressSummary ? ( @@ -584,9 +581,9 @@ export function IssueBlockedNotice({ ? <>Work on this task is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers. : <>Work on this task is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker. : reopenSuppressed - ? <>A message won’t move this back to todo yet — it stays blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} done, then it reopens automatically. Comments still wake {responsibleName} for questions or triage in the meantime. - : <>Work on this task is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the responsible for questions or triage. - : <>Work on this task is blocked until it is moved back to todo. Comments still wake the responsible for questions or triage.} + ? <>A message won’t restart this task yet — it stays blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} done, then it reopens automatically. Comments still notify {responsibleName} for questions or triage in the meantime. + : <>Work on this task is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still notify the assignee for questions or triage. + : <>Work on this task is blocked until someone moves it back to To do. Comments still notify the assignee for questions or triage.}

{reopenSuppressed && reopenSuppressedLeafId ? (

{ ); }); - expect(container.textContent).toContain("Work on this task is blocked by the linked task"); - expect(container.textContent).toContain("Comments still wake the responsible for questions or triage"); expect(container.textContent).toContain("PAP-1723"); expect(container.textContent).toContain("QA the install flow"); expect(container.querySelector('[data-issue-path-id="PAP-1723"]')).not.toBeNull(); diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 5613da8638..317f0cce50 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -130,7 +130,7 @@ describe("deriveRecoveryCardState", () => { }); describe("IssueRecoveryActionCard", () => { - it("renders required fields and an aria-label naming the state", () => { + it("renders state and kind attributes with owner names and the recorded next action", () => { const node = render( { />, ); const section = node.querySelector("section[aria-label]"); - expect(section?.getAttribute("aria-label")).toBe("Recovery action: needed"); + expect(section).not.toBeNull(); + expect(section?.getAttribute("data-recovery-state")).toBe("needed"); + expect(section?.getAttribute("data-recovery-kind")).toBe("missing_disposition"); expect(node.textContent).toContain("RECOVERY NEEDED"); expect(node.textContent).toContain("Missing Disposition"); - expect(node.textContent).not.toContain("missing_disposition"); - expect(node.textContent).toContain("This task's run finished, but no next step was chosen."); + expect(node.textContent).toContain( + "This task's run finished, but no next step was chosen. Choose what happens next — try the task again, mark it done, or send it for review.", + ); + expect(node.textContent).toContain("An agent will be asked to choose the next step"); expect(node.textContent).toContain("ClaudeCoder"); expect(node.textContent).toContain("CodexCoder"); expect(node.textContent).toContain("Choose and record a valid issue disposition."); - expect(node.textContent).toContain("Corrective wake queued"); - }); - - it("falls back to em dash when wake policy is absent", () => { - const node = render( - , - ); - expect(node.textContent).toContain("—"); }); it("renders observe_only tone for active_run_watchdog", () => { @@ -165,11 +161,31 @@ describe("IssueRecoveryActionCard", () => { , ); const section = node.querySelector("section[aria-label]"); - expect(section?.getAttribute("aria-label")).toBe("Recovery action: observing active run"); + expect(section?.getAttribute("data-recovery-state")).toBe("observe_only"); expect(node.textContent).toContain("OBSERVING ACTIVE RUN"); + expect(node.textContent).toContain( + "The active run has been silent. Recovery is observing without interrupting it.", + ); }); - it("renders a workspace-specific label and headline for workspace_validation", () => { + it("explains issue_graph_liveness in plain language", () => { + const node = render( + , + ); + expect(node.textContent).toContain("Task Needs Next Step"); + expect(node.textContent).toContain( + "Paperclip could not find a clear next step for this open task. Choose whether to continue work, send it for review, mark it done, or record what is blocking it.", + ); + }); + + it("falls back to an em dash when no evidence summary is available", () => { + const node = render(); + expect(node.textContent).toContain("—"); + }); + + it("renders workspace_validation with its kind attribute and the recorded next action", () => { const node = render( { const section = node.querySelector("section[aria-label]"); expect(section?.getAttribute("data-recovery-kind")).toBe("workspace_validation"); expect(node.textContent).toContain("Workspace Validation"); - expect(node.textContent).not.toContain("workspace_validation\n"); expect(node.textContent).toContain( "Paperclip stopped this run because the task's git workspace could not be validated.", ); expect(node.textContent).toContain("Repair the source issue workspace link"); - expect(node.textContent).toContain("Manual repair required"); }); it("renders a human evidence summary as prose, not a mono log line", () => { @@ -217,6 +231,9 @@ describe("IssueRecoveryActionCard", () => { expect(summary).toBeTruthy(); expect(summary?.className).toContain("text-xs"); expect(summary?.className).not.toContain("font-mono"); + expect(node.textContent).toContain( + "To get it moving, choose what happens next — try the task again, mark it done, or send it for review.", + ); }); it("keeps code-shaped evidence (error code, no summary) in the mono treatment", () => { @@ -238,11 +255,14 @@ describe("IssueRecoveryActionCard", () => { expect(code?.className).toContain("font-mono"); }); - it("renders the resolved label and outcome when resolved", () => { + it("renders the resolved state and outcome when resolved", () => { const node = render( , ); + const section = node.querySelector("section[aria-label]"); + expect(section?.getAttribute("data-recovery-state")).toBe("resolved"); expect(node.textContent).toContain("RECOVERY RESOLVED"); + expect(node.textContent).toContain("Recovery resolved as restored."); expect(node.textContent).toContain("Resolved as restored"); }); @@ -338,22 +358,35 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => { const diagnosis = node.querySelector("[data-testid='recovery-divergence-diagnosis']"); expect(diagnosis).not.toBeNull(); const text = diagnosis?.textContent ?? ""; + expect(text).toContain("Divergence diagnosis"); + expect(text).toContain("Expected · recorded"); + expect(text).toContain("Live · checked out"); expect(text).toContain("PAP-522-recorded"); expect(text).toContain("nleach/PAP-1405-live"); // shortened shas (10 chars) expect(text).toContain("aaaaaaaaaa"); expect(text).toContain("bbbbbbbbbb"); expect(text).toContain("cannot prove a forward-only reconciliation"); - expect(node.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toContain("Diverged"); + expect(node.querySelector("[data-testid='recovery-ancestry-verdict']")).not.toBeNull(); }); - it("labels an ancestor verdict as forward-only", () => { - const node = render( + it("labels each ancestry verdict", () => { + const diverged = render(); + expect(diverged.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toBe("Diverged"); + + const ancestor = render( , ); - expect(node.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toContain("Forward-only"); + expect(ancestor.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toBe("Forward-only"); + + const unknown = render( + , + ); + expect(unknown.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toBe("Ancestry unknown"); }); it("does not render a divergence diagnosis for non-incoherence workspace failures", () => { @@ -376,7 +409,6 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => { />, ); click(node.querySelector("[data-testid='recovery-action-reissue-trigger']")); - expect(document.body.textContent).toContain("Re-issue on isolated workspace"); click(document.body.querySelector("[data-testid='recovery-action-reissue-confirm']")); expect(onReissueIsolated).toHaveBeenCalledWith({ baseRef: "nleach/PAP-1405-live", @@ -492,14 +524,13 @@ describe("IssueRecoveryActionCard W7 reconcile actions", () => { ); click(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")); - // The confirm step restates the divergence: both branches, both short SHAs, and the verdict. + // The confirm step restates the divergence: both branches and both short SHAs. const restated = document.body.querySelector("[data-testid='recovery-breakglass-restated-divergence']"); const restatedText = restated?.textContent ?? ""; expect(restatedText).toContain("PAP-522-recorded"); expect(restatedText).toContain("nleach/PAP-1405-live"); expect(restatedText).toContain("aaaaaaaaaa"); expect(restatedText).toContain("bbbbbbbbbb"); - expect(restatedText).toContain("Diverged"); // The override is disabled until a non-empty reason is recorded. const confirm = document.body.querySelector( @@ -601,10 +632,12 @@ describe("IssueRecoveryActionCard repair workspace (quarantine_restore)", () => click(node.querySelector("[data-testid='recovery-action-repair-trigger']")); const restated = document.body.querySelector("[data-testid='recovery-repair-restated']"); const text = restated?.textContent ?? ""; - expect(text).toContain("3 uncommitted changes"); - // live branch is explicitly left untouched + expect( + document.body.querySelector("[data-testid='recovery-repair-dirty-count']")?.textContent, + ).toBe("3 uncommitted changes"); + // live branch is named in the restated summary, left untouched expect(text).toContain("nleach/PAP-1405-live"); - expect(text).toContain("left untouched"); + expect(text).toContain("(left untouched)"); // rescue branch preview mirrors the server naming (prefix + timestamp marker) expect( document.body.querySelector("[data-testid='recovery-repair-rescue-branch']")?.textContent, @@ -619,7 +652,7 @@ describe("IssueRecoveryActionCard repair workspace (quarantine_restore)", () => expect(onQuarantineRestore).toHaveBeenCalledTimes(1); }); - it("singularizes the dirty change count", () => { + it("singularizes a one-file dirty count", () => { const node = render( onReissueIsolated={() => {}} />, ); - // Diagnosis gains a claimant line naming the issue + active run. + // Diagnosis gains a claimant line naming the claiming issue. const notice = node.querySelector("[data-testid='recovery-contention-notice']"); + expect(notice?.textContent).toContain("Worktree claimed by"); expect(notice?.textContent).toContain("PAP-9001"); - expect(notice?.textContent).toContain("active run"); + expect(notice?.textContent).toContain("(active run)"); // The repair control is present but disabled, with the claimant as the explanation. const disabled = node.querySelector("[data-testid='recovery-action-repair-disabled']"); @@ -675,7 +709,9 @@ describe("IssueRecoveryActionCard repair workspace (quarantine_restore)", () => "[data-testid='recovery-action-repair-trigger']", ); expect(trigger?.disabled).toBe(true); - expect(disabled?.textContent).toContain("PAP-9001"); + expect(disabled?.textContent).toContain( + "Held by PAP-9001 — re-issue on an isolated workspace instead.", + ); // Clicking the disabled control never fires the repair. click(trigger ?? null); expect(onQuarantineRestore).not.toHaveBeenCalled(); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index e4d9e0e60d..6ce46fc2a7 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -120,13 +120,14 @@ const KIND_LABEL: Record = { workspace_validation: "Workspace Validation", configuration_validation: "Configuration Validation", active_run_watchdog: "Active Watchdog", - issue_graph_liveness: "Graph Liveness", + issue_graph_liveness: "Task Needs Next Step", }; const KIND_HEADLINE: Record = { - missing_disposition: "This task's run finished, but no next step was chosen.", + missing_disposition: + "This task's run finished, but no next step was chosen. Choose what happens next — try the task again, mark it done, or send it for review.", stranded_assigned_issue: - "Paperclip retried this task's last run and it still has no live execution path.", + "Paperclip retried this task's last run, but there is still no queued run, reviewer, blocker, or other next owner. To get it moving, choose what happens next — try the task again, mark it done, or send it for review.", workspace_validation: "Paperclip stopped this run because the task's git workspace could not be validated.", configuration_validation: @@ -134,7 +135,7 @@ const KIND_HEADLINE: Record = { active_run_watchdog: "The active run has been silent. Recovery is observing without interrupting it.", issue_graph_liveness: - "Paperclip detected this task lost a live action path. A recovery owner needs to act.", + "Paperclip could not find a clear next step for this open task. Choose whether to continue work, send it for review, mark it done, or record what is blocking it.", }; const STATE_TONE: Record {action.nextAction ? {action.nextAction} : } - + {wakeSummary ? {wakeSummary} : } {showAttempt ? (