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 ? (