From 1c75a46c106c058222c3a5df390a7e82e48fdebb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:59:18 -0500 Subject: [PATCH] feat(ui): add waiting-on-live-work blocked notice (#9298) 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. > - Operators rely on the issue detail thread to understand whether a task is blocked, live, or waiting on another task. > - A blocked issue can have a healthy blocker chain where downstream work is actively running and the parent will resume automatically. > - Showing that case with the same amber blocked notice as a stalled or attention-needed blocker makes the state look more severe than it is. > - The UI already receives blocker-attention state, blocker summaries, and company live-run ids, so this can be clarified without a new API shape. > - This pull request adds a blue "Waiting on live work" notice for covered blocker chains while preserving the existing amber notice for the other blocked states. > - The benefit is that operators can distinguish healthy queued work from blocked work that needs intervention. ## Linked Issues or Issue Description Refs #3820 Refs #8271 Related PR: #3877 Supersedes #9295 ## What Changed - Added a blue `IssueBlockedNotice` variant when `blockerAttention.state` is `covered` and the blocker chain has live work. - Rendered blocker-chain progress as done, running, and queued steps, including a "Now running" row for live terminal blockers. - Preserved the existing amber blocked notice for stalled, attention-needed, ordinary blocked, and successful-run handoff states. - Plumbed the existing `liveIssueIds`, `blockedBy`, and `blockerAttention` data from issue detail into the chat-thread blocked notice. - Added regression coverage around the covered live-work state, the no-confirmed-live fallback, numeric step ordering, and amber fallback states. - Hardened a low-trust server route test cleanup helper so CI deletes heartbeat run events before deleting heartbeat runs. ## Verification - `pnpm check:token-gates` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm exec vitest run ui/src/components/IssueBlockedNotice.test.tsx` - GitHub PR workflow is green on head `52ab6d9076ce233c183bf7133fa666e8597b6765`. - Greptile check is green on head `52ab6d9076ce233c183bf7133fa666e8597b6765` with zero unresolved review threads. ## Risks Low runtime risk: the product change is frontend-only and uses data already returned to the issue detail page. The main risk is visual regression in the blocked notice; the change keeps non-covered states on the existing amber path and adds focused regression coverage. The server-side change is test-only cleanup for an existing CI shard failure. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex using GPT-5, tool-use enabled in a repository workspace. The runtime did not expose a more specific model build id or context-window size. ## 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 --- .../low-trust-red-team-routes.test.ts | 1 + ui/src/components/IssueBlockedNotice.test.tsx | 112 +++++++ ui/src/components/IssueBlockedNotice.tsx | 276 +++++++++++++++++- ui/src/components/IssueChatThread.test.tsx | 262 +++++++++++++++++ ui/src/components/IssueChatThread.tsx | 5 + ui/src/pages/IssueDetail.tsx | 4 + 6 files changed, 659 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index bf972d0251..53a5f47a08 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -66,6 +66,7 @@ async function deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db: Db) { let lastError: unknown = null; for (let attempt = 0; attempt < 10; attempt += 1) { await db.delete(activityLog); + await db.delete(heartbeatRunEvents); try { await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 2bf0ba8443..f052ee41a3 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -223,6 +223,118 @@ describe("IssueBlockedNotice", () => { expect(node.textContent).toBe(""); }); + it("keeps the amber notice when a covered chain has no confirmed live blocker", () => { + const node = render( + , + ); + + expect(node.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull(); + expect(node.textContent).toContain("Work on this task is blocked by the linked task"); + expect(node.querySelector('[data-blocker-attention-state="covered"]')).not.toBeNull(); + }); + + it("sorts same-status live-work steps with numeric identifier ordering", () => { + const node = render( + , + ); + + const stepLinks = Array.from( + node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a'), + ).map((link) => link.textContent ?? ""); + + expect(stepLinks[0]).toContain("TASK-9"); + expect(stepLinks[1]).toContain("TASK-10"); + expect(stepLinks[2]).toContain("TASK-11"); + }); + it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => { const node = render( = new Set(); + +type WaitingStepStatus = "done" | "running" | "queued"; + +function classifyWaitingStep( + blocker: IssueRelationIssueSummary, + liveIds: ReadonlySet, +): WaitingStepStatus { + // A resolved blocker (done/cancelled) is a completed step; a blocker with a + // live run is the one currently being worked; everything else is queued. + if (blocker.status === "done" || blocker.status === "cancelled") return "done"; + if (liveIds.has(blocker.id)) return "running"; + return "queued"; +} + +// Ordering heuristic (plan §3): done → running → queued, tie-break by identifier +// (P1…Pn plan naming). The payload doesn't carry explicit chain order. +const WAITING_STEP_RANK: Record = { + done: 0, + running: 1, + queued: 2, +}; + +function WaitingChipLink({ + blocker, + running = false, +}: { + blocker: IssueRelationIssueSummary; + running?: boolean; +}) { + const issuePathId = blocker.identifier ?? blocker.id; + return ( + + {blocker.identifier ?? blocker.id.slice(0, 8)} + + {blocker.title} + + {running ? ( + + running + + ) : null} + + ); +} + +function WaitingStepGlyph({ status }: { status: WaitingStepStatus }) { + if (status === "done") { + return ; + } + if (status === "running") { + return ( + + + + ); + } + return ; +} + +/** + * Blue "Waiting on live work" variant — rendered in place of the + * amber notice when `blockerAttention.state === "covered"`: the blocker chain + * is a healthy plan executing in order and something in it is live. + */ +function WaitingOnLiveWorkNotice({ + blockerAttentionState, + chainBlockers, + terminalBlockers, + liveIds, + parkedBlockers, + renderParkedChip, +}: { + blockerAttentionState?: string; + chainBlockers: IssueRelationIssueSummary[]; + terminalBlockers: IssueRelationIssueSummary[]; + liveIds: ReadonlySet; + parkedBlockers: IssueRelationIssueSummary[]; + renderParkedChip: (blocker: IssueRelationIssueSummary) => ReactNode; +}) { + const steps = chainBlockers + .map((blocker) => ({ blocker, status: classifyWaitingStep(blocker, liveIds) })) + .sort((a, b) => { + const rank = WAITING_STEP_RANK[a.status] - WAITING_STEP_RANK[b.status]; + if (rank !== 0) return rank; + const aKey = a.blocker.identifier ?? a.blocker.id; + const bKey = b.blocker.identifier ?? b.blocker.id; + return aKey.localeCompare(bKey, undefined, { numeric: true }); + }); + const total = steps.length; + const doneCount = steps.filter((step) => step.status === "done").length; + const runningCount = steps.filter((step) => step.status === "running").length; + + // "Now running" replaces "Ultimately waiting on": prefer live terminal + // leaves; otherwise fall back to whichever chain blocker is live. + const nowRunningSeen = new Set(); + const nowRunning: IssueRelationIssueSummary[] = []; + for (const blocker of [...terminalBlockers, ...chainBlockers]) { + if (!liveIds.has(blocker.id)) continue; + if (nowRunningSeen.has(blocker.id)) continue; + nowRunningSeen.add(blocker.id); + nowRunning.push(blocker); + } + + const queuedNoun = total === 1 ? "task" : "tasks"; + + return ( +
+
+ + + +
+
+

Waiting on live work

+

+ Queued behind {total} {queuedNoun} being worked in order. This task + resumes automatically when the chain is done. Comments still wake the + responsible agent. +

+
+ +
+
+ {doneCount} of {total} done + {runningCount > 0 ? ` · ${runningCount} running` : null} +
+
+ {steps.map(({ blocker, status }) => ( + + ))} +
+
+ +
+ {steps.map(({ blocker, status }) => ( +
+
+ + + + +
+
+ {status === "running" ? ( +
+ +
+ ) : ( + + )} +
+
+ ))} +
+
+ +
+
+ + This task — resumes automatically when the chain is done + +
+
+
+ + {nowRunning.length > 0 ? ( +
+ + Now running + + {nowRunning.map((blocker) => ( + + ))} +
+ ) : null} + + {parkedBlockers.length > 0 ? ( +
+ + + Blocked by parked work + + {parkedBlockers.map((blocker) => renderParkedChip(blocker))} +
+ ) : null} +
+
+
+ ); +} + export function IssueBlockedNotice({ issueId, issueStatus, blockers, + allBlockers, + liveIssueIds, blockerAttention, successfulRunHandoff, scheduledRetry, @@ -119,7 +355,16 @@ export function IssueBlockedNotice({ }: { issueId?: string | null; issueStatus?: string; + /** Unresolved blockers (drives the amber notice; unchanged). */ blockers: IssueRelationIssueSummary[]; + /** + * Full blocker list (resolved + unresolved). Used by the blue "Waiting on + * live work" variant to render done steps and progress counts. Falls back to + * {@link blockers} when not supplied. + */ + allBlockers?: IssueRelationIssueSummary[]; + /** Company-wide set of issue ids with a queued/running run (own or blocker). */ + liveIssueIds?: ReadonlySet; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; scheduledRetry?: IssueScheduledRetry | null; @@ -198,6 +443,35 @@ export function IssueBlockedNotice({ ); }; + // Blue "Waiting on live work" variant: the blocker chain is a healthy plan + // executing in order and something in it is live. `covered` is + // the only state that goes blue — stalled / needs_attention / none keep the + // amber notice byte-for-byte. The successful-run handoff notice is about this + // task's own finished run, so it always keeps its amber priority styling. + const liveIds = liveIssueIds ?? EMPTY_LIVE_IDS; + const chainBlockers = allBlockers ?? blockers; + const hasLiveWaitingBlocker = [...chainBlockers, ...terminalBlockers].some((blocker) => ( + liveIds.has(blocker.id) + )); + const waitingOnLiveWork = + !showSuccessfulRunHandoff + && blockerAttention?.state === "covered" + && chainBlockers.length > 0 + && hasLiveWaitingBlocker; + + if (waitingOnLiveWork) { + return ( + + ); + } + return (
{ }); }); + it("renders the blue 'Waiting on live work' variant when the blocker chain is covered", () => { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const notice = container.querySelector('[data-testid="issue-blocked-notice-live"]'); + expect(notice).not.toBeNull(); + expect(notice?.getAttribute("data-blocker-attention-state")).toBe("covered"); + expect(container.textContent).toContain("Waiting on live work"); + expect(container.textContent).toContain("resumes automatically when the chain is done"); + // Progress counts: 1 done, 1 running out of 3. + expect(container.textContent).toContain("1 of 3 done · 1 running"); + // Amber "Ultimately waiting on" / "blocked by the linked task" copy is gone. + expect(container.textContent).not.toContain("Ultimately waiting on"); + expect(container.textContent).not.toContain("Work on this task is blocked by"); + // All three blockers render as chips. + expect(container.querySelector('[data-issue-path-id="PAP-2001"]')).not.toBeNull(); + expect(container.querySelector('[data-issue-path-id="PAP-2002"]')).not.toBeNull(); + expect(container.querySelector('[data-issue-path-id="PAP-2003"]')).not.toBeNull(); + + flushAct(() => { + root.unmount(); + }); + }); + + it("shows a 'Now running' row replacing 'Ultimately waiting on' when the terminal leaf is live", () => { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const nowRunning = container.querySelector('[data-testid="issue-blocked-notice-now-running"]'); + expect(nowRunning).not.toBeNull(); + expect(nowRunning?.textContent).toContain("Now running"); + expect(nowRunning?.textContent).toContain("PAP-3002"); + expect(container.textContent).not.toContain("Ultimately waiting on"); + + flushAct(() => { + root.unmount(); + }); + }); + + it("keeps the parked-work row amber inside the blue variant", () => { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.querySelector('[data-testid="issue-blocked-notice-live"]')).not.toBeNull(); + const parkedRow = container.querySelector('[data-testid="issue-blocked-notice-parked-row"]'); + expect(parkedRow).not.toBeNull(); + expect(parkedRow?.textContent).toContain("Blocked by parked work"); + // Parked label keeps its amber tone even inside the blue box. + expect(parkedRow?.querySelector(".text-amber-800")).not.toBeNull(); + + flushAct(() => { + root.unmount(); + }); + }); + + it("keeps the amber notice byte-for-byte for stalled / needs_attention / none states", () => { + for (const state of ["stalled", "needs_attention", "none"] as const) { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + // Blue variant never appears for non-covered states. + expect(container.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull(); + // The amber container (with the canonical attention data attribute) does. + const amber = container.querySelector(`[data-blocker-attention-state="${state}"]`); + expect(amber).not.toBeNull(); + expect(amber?.className).toContain("border-amber-300/70"); + if (state === "stalled") { + expect(container.textContent).toContain("Stalled in review"); + } + + flushAct(() => { + root.unmount(); + }); + } + }); + it("shows paused responsible agent context above the composer", () => { const root = createRoot(container); const pausedAgent = { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 04f2199df3..11b5f0e4f9 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -422,6 +422,8 @@ interface IssueChatThreadProps { activeRun?: ActiveRunForIssue | null; issueId?: string | null; blockedBy?: IssueRelationIssueSummary[]; + /** Company-wide set of issue ids with a live (queued/running) run. */ + liveIssueIds?: ReadonlySet; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; scheduledRetry?: IssueScheduledRetry | null; @@ -4164,6 +4166,7 @@ export function IssueChatThread({ activeRun = null, issueId = null, blockedBy = [], + liveIssueIds, blockerAttention = null, successfulRunHandoff = null, scheduledRetry = null, @@ -4931,6 +4934,8 @@ export function IssueChatThread({ issueId={issueId} issueStatus={issueStatus} blockers={unresolvedBlockers} + allBlockers={blockedBy} + liveIssueIds={liveIssueIds} blockerAttention={blockerAttention} successfulRunHandoff={recoveryAction ? null : successfulRunHandoff} scheduledRetry={scheduledRetry} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b4a078a71d..9c53f284f1 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -887,6 +887,7 @@ type IssueDetailChatTabProps = { issueWorkMode: IssueWorkMode; executionRunId: string | null; blockedBy: Issue["blockedBy"]; + liveIssueIds: ReadonlySet; blockerAttention: Issue["blockerAttention"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null; scheduledRetry: Issue["scheduledRetry"] | null; @@ -970,6 +971,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueStatus, executionRunId, blockedBy, + liveIssueIds, blockerAttention, successfulRunHandoff, scheduledRetry, @@ -1193,6 +1195,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ activeRun={resolvedActiveRun} issueId={issueId} blockedBy={blockedBy ?? []} + liveIssueIds={liveIssueIds} blockerAttention={blockerAttention} successfulRunHandoff={successfulRunHandoff} scheduledRetry={scheduledRetry} @@ -4664,6 +4667,7 @@ export function IssueDetail() { issueWorkMode={issue.workMode ?? "standard"} executionRunId={issue.executionRunId ?? null} blockedBy={issue.blockedBy ?? []} + liveIssueIds={liveIssueIds} blockerAttention={issue.blockerAttention ?? null} successfulRunHandoff={issue.successfulRunHandoff ?? null} scheduledRetry={issue.scheduledRetry ?? null}