feat(ui): add waiting-on-live-work blocked notice (#9298)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-09 10:59:18 -05:00 committed by GitHub
parent 97e0752158
commit 1c75a46c10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 659 additions and 1 deletions

View File

@ -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);

View File

@ -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(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["unrelated-live"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-1",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Dependency work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
allBlockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Dependency work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
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(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["blocker-11"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 3,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-11",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-11",
identifier: "TASK-11",
title: "Running work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
allBlockers={[
{
id: "blocker-10",
identifier: "TASK-10",
title: "Tenth done step",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-9",
identifier: "TASK-9",
title: "Ninth done step",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-11",
identifier: "TASK-11",
title: "Running work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
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(
<IssueBlockedNotice

View File

@ -5,8 +5,10 @@ import type {
IssueScheduledRetry,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { AlertTriangle, CheckCircle2, Circle, Flag, Loader2, RotateCcw } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatMonitorOffset } from "../lib/issue-monitor";
@ -108,10 +110,244 @@ function SuccessfulRunRetryNowControl({
);
}
const EMPTY_LIVE_IDS: ReadonlySet<string> = new Set<string>();
type WaitingStepStatus = "done" | "running" | "queued";
function classifyWaitingStep(
blocker: IssueRelationIssueSummary,
liveIds: ReadonlySet<string>,
): 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<WaitingStepStatus, number> = {
done: 0,
running: 1,
queued: 2,
};
function WaitingChipLink({
blocker,
running = false,
}: {
blocker: IssueRelationIssueSummary;
running?: boolean;
}) {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<IssueLinkQuicklook
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-blue-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-blue-950 transition-colors hover:border-blue-500 hover:bg-blue-100 hover:underline dark:border-blue-500/40 dark:bg-background/40 dark:text-blue-100 dark:hover:bg-blue-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-(--sz-18rem) truncate font-sans text-(length:--text-micro) text-blue-800 dark:text-blue-200">
{blocker.title}
</span>
{running ? (
<span className="ml-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-wide text-blue-700 dark:bg-blue-400/20 dark:text-blue-200">
running
</span>
) : null}
</IssueLinkQuicklook>
);
}
function WaitingStepGlyph({ status }: { status: WaitingStepStatus }) {
if (status === "done") {
return <CheckCircle2 className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" aria-hidden />;
}
if (status === "running") {
return (
<span className="flex h-3.5 w-3.5 items-center justify-center" aria-hidden>
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" />
</span>
);
}
return <Circle className="h-3.5 w-3.5 text-blue-300 dark:text-blue-500/50" aria-hidden />;
}
/**
* 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<string>;
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<string>();
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 (
<div
data-blocker-attention-state={blockerAttentionState}
data-testid="issue-blocked-notice-live"
className="mb-3 rounded-md border border-blue-300/70 bg-blue-50/90 px-3 py-2.5 text-sm text-blue-950 shadow-sm dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-100"
>
<div className="flex items-start gap-2">
<span className="mt-1.5 flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<p className="font-medium leading-5">Waiting on live work</p>
<p className="leading-5">
Queued behind {total} {queuedNoun} being worked in order. This task
resumes automatically when the chain is done. Comments still wake the
responsible agent.
</p>
</div>
<div className="space-y-1" data-testid="issue-blocked-notice-progress">
<div className="text-xs font-medium text-blue-800 dark:text-blue-200">
{doneCount} of {total} done
{runningCount > 0 ? ` · ${runningCount} running` : null}
</div>
<div
role="progressbar"
aria-label="Blocker chain progress"
aria-valuemin={0}
aria-valuenow={doneCount}
aria-valuemax={total}
className="flex h-2 w-full overflow-hidden rounded-full bg-blue-100 dark:bg-blue-500/20"
>
{steps.map(({ blocker, status }) => (
<span
key={blocker.id}
className={cn(
"h-full border-r border-blue-50/80 last:border-r-0 dark:border-blue-950/40",
status === "done"
? "bg-blue-500 dark:bg-blue-400"
: status === "running"
? "animate-pulse bg-blue-400"
: "bg-blue-200 dark:bg-blue-500/30",
)}
style={{ width: `${100 / total}%` }}
title={`${blocker.identifier ?? blocker.id.slice(0, 8)}: ${status}`}
aria-hidden
/>
))}
</div>
</div>
<div data-testid="issue-blocked-notice-steps">
{steps.map(({ blocker, status }) => (
<div key={blocker.id} className="flex items-stretch gap-2">
<div className="flex w-3.5 flex-col items-center">
<span className="mt-0.5">
<WaitingStepGlyph status={status} />
</span>
<span
className="w-px flex-1 bg-blue-300/50 dark:bg-blue-500/30"
aria-hidden
/>
</div>
<div className="min-w-0 pb-1.5">
{status === "running" ? (
<div className="rounded-md border border-blue-500/60 bg-blue-100/60 p-1 dark:border-blue-400/50 dark:bg-blue-500/15">
<WaitingChipLink blocker={blocker} running />
</div>
) : (
<WaitingChipLink blocker={blocker} />
)}
</div>
</div>
))}
<div className="flex items-stretch gap-2">
<div className="flex w-3.5 flex-col items-center">
<span
className="mt-0.5 h-3 w-3 rounded-full border border-dashed border-blue-400/60 dark:border-blue-400/50"
aria-hidden
/>
</div>
<div className="min-w-0 pb-0.5">
<span className="inline-block rounded-md border border-dashed border-blue-300/70 px-2 py-1 text-xs text-blue-800 dark:border-blue-500/40 dark:text-blue-200">
This task — resumes automatically when the chain is done
</span>
</div>
</div>
</div>
{nowRunning.length > 0 ? (
<div
data-testid="issue-blocked-notice-now-running"
className="flex flex-wrap items-center gap-1.5 pt-0.5"
>
<span className="text-xs font-medium text-blue-800 dark:text-blue-200">
Now running
</span>
{nowRunning.map((blocker) => (
<WaitingChipLink key={blocker.id} blocker={blocker} running />
))}
</div>
) : null}
{parkedBlockers.length > 0 ? (
<div
data-testid="issue-blocked-notice-parked-row"
className="flex flex-wrap items-center gap-1.5 pt-0.5"
>
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-200">
<Flag className="h-3 w-3" aria-hidden />
Blocked by parked work
</span>
{parkedBlockers.map((blocker) => renderParkedChip(blocker))}
</div>
) : null}
</div>
</div>
</div>
);
}
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<string>;
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 (
<WaitingOnLiveWorkNotice
blockerAttentionState={blockerAttention?.state}
chainBlockers={chainBlockers}
terminalBlockers={terminalBlockers}
liveIds={liveIds}
parkedBlockers={showParkedRow ? parkedBlockers : []}
renderParkedChip={renderBlockerChip}
/>
);
}
return (
<div
data-blocker-attention-state={blockerAttention?.state}

View File

@ -2067,6 +2067,268 @@ describe("IssueChatThread", () => {
});
});
it("renders the blue 'Waiting on live work' variant when the blocker chain is covered", () => {
const root = createRoot(container);
flushAct(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="blocked"
liveIssueIds={new Set(["blocker-run"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 3,
coveredBlockerCount: 3,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2002",
sampleStalledBlockerIdentifier: null,
}}
blockedBy={[
{
id: "blocker-done",
identifier: "PAP-2001",
title: "Server work",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-run",
identifier: "PAP-2002",
title: "UI work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
},
{
id: "blocker-queued",
identifier: "PAP-2003",
title: "QA gate",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-3",
assigneeUserId: null,
},
]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="blocked"
liveIssueIds={new Set(["terminal-live"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-3001",
sampleStalledBlockerIdentifier: null,
}}
blockedBy={[
{
id: "blocker-mid",
identifier: "PAP-3001",
title: "Phase 7 review",
status: "blocked",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
terminalBlockers: [
{
id: "terminal-live",
identifier: "PAP-3002",
title: "Security sign-off",
status: "in_progress",
priority: "high",
assigneeAgentId: "agent-2",
assigneeUserId: null,
},
],
},
]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="blocked"
liveIssueIds={new Set(["blocker-run"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-4001",
sampleStalledBlockerIdentifier: null,
}}
blockedBy={[
{
id: "blocker-run",
identifier: "PAP-4001",
title: "UI work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-parked",
identifier: "PAP-4002",
title: "Parked backlog task",
status: "backlog",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
},
]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="blocked"
liveIssueIds={new Set(["blocker-1"])}
blockerAttention={{
state,
reason: state === "stalled" ? "stalled_review" : null,
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: state === "stalled" ? 1 : 0,
attentionBlockerCount: state === "needs_attention" ? 1 : 0,
sampleBlockerIdentifier: "PAP-5001",
sampleStalledBlockerIdentifier: state === "stalled" ? "PAP-5001" : null,
}}
blockedBy={[
{
id: "blocker-1",
identifier: "PAP-5001",
title: "Review task",
status: "in_review",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
// 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 = {

View File

@ -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<string>;
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}

View File

@ -887,6 +887,7 @@ type IssueDetailChatTabProps = {
issueWorkMode: IssueWorkMode;
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
liveIssueIds: ReadonlySet<string>;
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}