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:
parent
97e0752158
commit
1c75a46c10
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue