PAP-2731 Implement sky explicit-wait UI surfaces

Replaces the rose liveness-break banner for issues whose blocker-attention
read model reports `state="covered"` with `reason="explicit_waiting"`.

- IssueBlockedNotice: add a sky-toned banner with Hourglass icon, kind-aware
  headline/body copy (plan confirmation, user response, suggested tasks,
  board approval, generic fallback), Target/Owner/Resume pill row, a "Jump
  to confirmation" anchor, and a sky chip strip for parent-of-leaf chain
  waits. Rose banner still wins on tie via the existing precedence.
- StatusIcon: add a sky ring + bottom-right nub for explicit_waiting and
  emit `data-blocker-attention-state="explicit_waiting"`. Tooltip is
  "Waiting on board/user/agent" or prefixed "Blocked · ..." when blocked.
- IssueRow: add a sky `Waiting · {owner}` pill (collapses to `Waiting` on
  mobile) when the row's attention reason is explicit_waiting. Owner label
  resolves via new optional agent/user maps wired in by IssuesList & Inbox.
- IssueDetail/IssueChatThread: lift the linkedApprovals query into the chat
  tab and pass interactions/approvals/agentMap/userLabelMap/currentUserId
  through to the new banner.
- Storybook + tests: extend coveredBlockedMatrix with sky cells, add four
  new BlockedNotice fixtures (plan, user response, board approval, chain),
  and cover the new states with unit tests on StatusIcon, IssueBlockedNotice,
  and IssueRow.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 19:00:13 -05:00
1 parent 5adbe69381
commit 08a9e0caf0
12 files changed
+911 -50

No files matched your search

@@ -97,6 +97,110 @@ describe("IssueBlockedNotice", () => {
expect(html).toContain("Wake to continue");
});
it("renders the sky explicit-wait banner with plan-confirmation copy when a pending request_confirmation exists", () => {
const html = renderNotice(
<>
<IssueBlockedNotice
issueIdentifier="PAP-2708"
issueStatus="in_review"
blockers={[]}
blockerAttention={{
state: "covered",
reason: "explicit_waiting",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2708",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}}
interactions={[
{
id: "interaction-1",
companyId: "co-1",
issueId: "issue-1",
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
createdAt: "2026-04-28T22:00:00.000Z",
updatedAt: "2026-04-28T22:00:00.000Z",
createdByAgentId: "agent-coder",
createdByUserId: null,
payload: {
version: 1,
prompt: "Approve plan",
target: {
type: "issue_document",
key: "plan",
revisionId: "rev-1",
revisionNumber: 2,
},
},
},
]}
/>
</>,
);
expect(html).toContain('data-blocker-attention-state="covered"');
expect(html).toContain('data-blocker-attention-reason="explicit_waiting"');
expect(html).toContain("Waiting on board confirmation");
expect(html).toContain("Plan revision r2");
expect(html).toContain("Resumes when the board accepts");
expect(html).toContain("border-sky-300/70");
expect(html).not.toContain("border-rose-300/70");
expect(html).toContain("Jump to confirmation");
});
it("renders the sky explicit-wait banner falling back to a board-approval message when only an approval is pending", () => {
const html = renderNotice(
<>
<IssueBlockedNotice
issueIdentifier="PAP-2710"
issueStatus="in_review"
blockers={[]}
blockerAttention={{
state: "covered",
reason: "explicit_waiting",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2710",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}}
interactions={[]}
approvals={[
{
id: "approval-abcdef12",
companyId: "co-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-04-28T22:00:00.000Z"),
updatedAt: new Date("2026-04-28T22:00:00.000Z"),
},
]}
/>
</>,
);
expect(html).toContain("Waiting on board approval");
expect(html).toContain("A board approval is open");
expect(html).toContain("Approval approval");
expect(html).toContain("Jump to approval");
expect(html).toContain("border-sky-300/70");
});
it("falls back to amber stalled treatment when state is stalled rather than recovery_needed", () => {
const html = renderNotice(
<>
+398 -29
View File
@@ -1,6 +1,16 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import type {
Approval,
AskUserQuestionsInteraction,
AskUserQuestionsPayload,
IssueBlockerAttention,
IssueRelationIssueSummary,
IssueThreadInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@paperclipai/shared";
import { AlertTriangle, Hourglass } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { Identity } from "./Identity";
@@ -22,23 +32,94 @@ const NEXT_ACTION_VERB: Record<NonNullable<IssueBlockerAttention["nextActionHint
reassign: "Reassign",
};
type WaitingActionableInteraction =
| RequestConfirmationInteraction
| AskUserQuestionsInteraction
| SuggestTasksInteraction;
const WAITING_INTERACTION_KINDS: ReadonlySet<string> = new Set([
"request_confirmation",
"ask_user_questions",
"suggest_tasks",
]);
function isWaitingActionableInteraction(
interaction: IssueThreadInteraction,
): interaction is WaitingActionableInteraction {
return WAITING_INTERACTION_KINDS.has(interaction.kind);
}
function pickLatestPendingInteraction(
interactions: IssueThreadInteraction[] | null | undefined,
): WaitingActionableInteraction | null {
if (!interactions || interactions.length === 0) return null;
let best: WaitingActionableInteraction | null = null;
let bestTime = -Infinity;
for (const interaction of interactions) {
if (interaction.status !== "pending") continue;
if (!isWaitingActionableInteraction(interaction)) continue;
const time = new Date(interaction.createdAt as string | Date).getTime();
if (Number.isFinite(time) && time > bestTime) {
bestTime = time;
best = interaction;
}
}
return best;
}
function pickLatestPendingApproval(approvals: Approval[] | null | undefined): Approval | null {
if (!approvals || approvals.length === 0) return null;
let best: Approval | null = null;
let bestTime = -Infinity;
for (const approval of approvals) {
if (approval.status !== "pending") continue;
const time = new Date(approval.createdAt as string | Date).getTime();
if (Number.isFinite(time) && time > bestTime) {
bestTime = time;
best = approval;
}
}
return best;
}
export function IssueBlockedNotice({
issueId,
issueIdentifier,
issueStatus,
blockers,
blockerAttention,
ownerAgentName,
ownerUserName,
interactions,
approvals,
agentMap,
userLabelMap,
currentUserId,
}: {
issueId?: string | null;
issueIdentifier?: string | null;
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
ownerAgentName?: string | null;
ownerUserName?: string | null;
interactions?: IssueThreadInteraction[] | null;
approvals?: Approval[] | null;
agentMap?: ReadonlyMap<string, { name: string }> | null;
userLabelMap?: ReadonlyMap<string, string> | null;
currentUserId?: string | null;
}) {
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed"
&& blockerAttention.reason !== null
&& RECOVERY_REASONS.has(blockerAttention.reason);
if (blockers.length === 0 && issueStatus !== "blocked" && !isRecoveryNeeded) return null;
const isExplicitWaiting = blockerAttention?.state === "covered"
&& blockerAttention.reason === "explicit_waiting";
if (
blockers.length === 0
&& issueStatus !== "blocked"
&& !isRecoveryNeeded
&& !isExplicitWaiting
) return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -86,37 +167,79 @@ export function IssueBlockedNotice({
}
return candidates;
})();
// Leaf-level surface: the issue itself is the invalid leaf. We render the
// rose notice without any blocker chips, using the leaf-perspective copy.
const isLeafSurface = isRecoveryNeeded && issueStatus !== "blocked";
const containerClass = isRecoveryNeeded
? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100"
: "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100";
const iconClass = isRecoveryNeeded
? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300"
: "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300";
const subRowLabelClass = isRecoveryNeeded
? "text-xs font-medium text-rose-800 dark:text-rose-200"
: "text-xs font-medium text-amber-800 dark:text-amber-200";
// Explicit-wait wins over rose only when state is actually "covered". The
// server already gates these so they cannot co-occur on the same issue (rose
// wins on tie via the precedence: recovery_needed first, then explicit
// waiting), but check explicitly per the spec.
const showExplicitWaiting = isExplicitWaiting && !isRecoveryNeeded;
const waitLeafIdentifier = showExplicitWaiting
? blockerAttention?.sampleBlockerIdentifier ?? null
: null;
const isWaitChain = showExplicitWaiting
&& waitLeafIdentifier !== null
&& issueIdentifier != null
&& waitLeafIdentifier !== issueIdentifier;
const waitLeafBlockers = (() => {
if (!showExplicitWaiting || !isWaitChain) return [] as IssueRelationIssueSummary[];
const candidates: IssueRelationIssueSummary[] = [];
for (const blocker of [...blockers, ...terminalBlockers]) {
if (blocker.status === "done" || blocker.status === "cancelled") continue;
if (candidates.some((existing) => existing.id === blocker.id)) continue;
candidates.push(blocker);
}
if (waitLeafIdentifier) {
const preferred = candidates.find(
(blocker) => (blocker.identifier ?? blocker.id) === waitLeafIdentifier,
);
if (preferred) {
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
}
}
return candidates;
})();
const renderBlockerChip = (blocker: IssueRelationIssueSummary, variant: "amber" | "rose" = "amber") => {
const containerClass = showExplicitWaiting
? "mb-3 rounded-md border border-sky-300/70 bg-sky-50/90 px-3 py-2.5 text-sm text-sky-950 shadow-sm dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100"
: isRecoveryNeeded
? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100"
: "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100";
const iconClass = showExplicitWaiting
? "mt-0.5 h-4 w-4 shrink-0 text-sky-600 dark:text-sky-300"
: isRecoveryNeeded
? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300"
: "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300";
const subRowLabelClass = showExplicitWaiting
? "text-xs font-medium text-sky-800 dark:text-sky-200"
: isRecoveryNeeded
? "text-xs font-medium text-rose-800 dark:text-rose-200"
: "text-xs font-medium text-amber-800 dark:text-amber-200";
const renderBlockerChip = (
blocker: IssueRelationIssueSummary,
variant: "amber" | "rose" | "sky" = "amber",
) => {
const issuePathId = blocker.identifier ?? blocker.id;
const chipClass = variant === "rose"
? "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15"
: "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15";
const labelClass = variant === "rose"
? "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200"
: "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200";
const chipClassByVariant: Record<typeof variant, string> = {
amber: "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15",
rose: "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15",
sky: "inline-flex max-w-full items-center gap-1 rounded-md border border-sky-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-sky-950 transition-colors hover:border-sky-500 hover:bg-sky-100 hover:underline dark:border-sky-500/40 dark:bg-background/40 dark:text-sky-100 dark:hover:bg-sky-500/15",
};
const labelClassByVariant: Record<typeof variant, string> = {
amber: "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200",
rose: "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200",
sky: "max-w-[18rem] truncate font-sans text-[11px] text-sky-800 dark:text-sky-200",
};
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className={chipClass}
className={chipClassByVariant[variant]}
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className={labelClass}>{blocker.title}</span>
<span className={labelClassByVariant[variant]}>{blocker.title}</span>
</IssueLinkQuicklook>
);
};
@@ -154,6 +277,229 @@ export function IssueBlockedNotice({
);
};
// ----- Explicit waiting helpers -----
const ownerLabel = (() => {
const owner = blockerAttention?.nextActionOwner ?? null;
if (!owner) return "the next reviewer";
if (owner.type === "user") {
if (!owner.userId) return "the board";
const resolved = formatAssigneeUserLabel(owner.userId, currentUserId ?? null, userLabelMap ?? undefined);
if (resolved) return resolved;
return ownerUserName ?? "the user";
}
if (owner.type === "agent") {
if (owner.agentId && agentMap) {
const agent = agentMap.get(owner.agentId);
if (agent?.name) return agent.name;
}
return ownerAgentName ?? "the agent";
}
return "the next reviewer";
})();
const pendingInteraction = showExplicitWaiting && !isWaitChain
? pickLatestPendingInteraction(interactions ?? null)
: null;
const pendingApproval = showExplicitWaiting && !pendingInteraction && !isWaitChain
? pickLatestPendingApproval(approvals ?? null)
: null;
const isPlanConfirmation = (interaction: WaitingActionableInteraction): boolean => {
if (interaction.kind !== "request_confirmation") return false;
const target = interaction.payload?.target ?? null;
if (!target) return false;
if (target.type === "issue_document") return target.key === "plan";
return false;
};
const explicitWaitingHeadline = (() => {
if (!showExplicitWaiting) return null;
if (isWaitChain) {
return <>Waiting on {ownerLabel} downstream.</>;
}
if (pendingInteraction) {
if (pendingInteraction.kind === "request_confirmation") {
if (isPlanConfirmation(pendingInteraction)) {
return <>Waiting on board confirmation.</>;
}
return <>Waiting on confirmation.</>;
}
if (pendingInteraction.kind === "ask_user_questions") {
return <>Waiting on user response.</>;
}
if (pendingInteraction.kind === "suggest_tasks") {
return <>Waiting on task selection.</>;
}
}
if (pendingApproval) {
return <>Waiting on board approval.</>;
}
return <>Waiting on {ownerLabel}.</>;
})();
const explicitWaitingBody = (() => {
if (!showExplicitWaiting) return null;
if (isWaitChain) {
const leafChip = waitLeafIdentifier
? <span className="font-mono text-xs">[{waitLeafIdentifier}]</span>
: null;
return leafChip
? <>Work on this issue is paused while {ownerLabel} responds in {leafChip}. Nothing here will resume until that is resolved.</>
: <>Work on this issue is paused while {ownerLabel} responds downstream. Nothing here will resume until that is resolved.</>;
}
if (pendingInteraction) {
if (pendingInteraction.kind === "request_confirmation") {
if (isPlanConfirmation(pendingInteraction)) {
return <>Paperclip drafted a plan and asked the board to accept or reject it. Nothing will run until that's resolved.</>;
}
return <>Paperclip is waiting on {ownerLabel} to accept or reject.</>;
}
if (pendingInteraction.kind === "ask_user_questions") {
const payload = pendingInteraction.payload as AskUserQuestionsPayload;
const count = payload?.questions?.length ?? 0;
const noun = count === 1 ? "question" : "questions";
if (count > 0) {
return <>Paperclip asked {ownerLabel} {count} {noun} before continuing.</>;
}
return <>Paperclip asked {ownerLabel} for a response before continuing.</>;
}
if (pendingInteraction.kind === "suggest_tasks") {
const count = pendingInteraction.payload?.tasks?.length ?? 0;
if (count > 0) {
return <>Paperclip suggested {count} {count === 1 ? "task" : "tasks"}; pick which to create.</>;
}
return <>Paperclip suggested tasks for {ownerLabel} to pick which to create.</>;
}
}
if (pendingApproval) {
return <>A board approval is open against this issue.</>;
}
return <>This issue is on hold pending a human decision.</>;
})();
const explicitWaitingTargetLabel = (() => {
if (!pendingInteraction) {
if (pendingApproval) {
const id = pendingApproval.id;
return `Approval ${id.slice(0, 8)}`;
}
return null;
}
if (pendingInteraction.kind === "request_confirmation") {
const target = pendingInteraction.payload?.target ?? null;
if (target?.type === "issue_document" && target.key === "plan") {
const revLabel = target.revisionNumber != null ? `r${target.revisionNumber}` : "latest";
return `Plan revision ${revLabel}`;
}
if (target?.label) return target.label;
if (target?.type === "custom" && target.key) return target.key;
return "Confirmation";
}
if (pendingInteraction.kind === "ask_user_questions") {
const payload = pendingInteraction.payload as AskUserQuestionsPayload;
const count = payload?.questions?.length ?? 0;
if (count === 0) return "Questions";
return `${count} ${count === 1 ? "question" : "questions"}`;
}
if (pendingInteraction.kind === "suggest_tasks") {
const count = pendingInteraction.payload?.tasks?.length ?? 0;
if (count === 0) return "Suggested tasks";
return `${count} suggested ${count === 1 ? "task" : "tasks"}`;
}
return null;
})();
const explicitWaitingResumeLabel = (() => {
if (pendingInteraction) {
const policy = pendingInteraction.continuationPolicy ?? "none";
if (policy === "wake_assignee" || policy === "wake_assignee_on_accept") {
if (pendingInteraction.kind === "request_confirmation") {
return `Resumes when ${ownerLabel} accepts. Rejection returns to the assignee with reason.`;
}
if (pendingInteraction.kind === "ask_user_questions") {
return `Resumes when ${ownerLabel} answers.`;
}
if (pendingInteraction.kind === "suggest_tasks") {
return `Resumes when ${ownerLabel} picks tasks to create.`;
}
}
return "Does not auto-resume — assignee must restart.";
}
if (pendingApproval) {
return "Resumes on approval decision.";
}
return null;
})();
const explicitWaitingPills = (() => {
if (!showExplicitWaiting) return null;
const pills: React.ReactNode[] = [];
if (explicitWaitingTargetLabel) {
pills.push(
<span
key="target"
className="inline-flex items-center gap-1 rounded-md border border-sky-300/70 bg-white/80 px-1.5 py-0.5 text-[11px] font-medium text-sky-900 dark:border-sky-500/40 dark:bg-background/40 dark:text-sky-100"
>
<span className="text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80">
Target
</span>
<span className="truncate max-w-[16rem]">{explicitWaitingTargetLabel}</span>
</span>,
);
}
pills.push(
<span
key="owner"
className="inline-flex items-center gap-1 rounded-md border border-sky-300/70 bg-white/80 px-1.5 py-0.5 text-[11px] font-medium text-sky-900 dark:border-sky-500/40 dark:bg-background/40 dark:text-sky-100"
>
<span className="text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80">
Owner
</span>
<span className="truncate max-w-[12rem]">{ownerLabel}</span>
</span>,
);
if (explicitWaitingResumeLabel) {
pills.push(
<span
key="resume"
className="inline-flex items-center gap-1 rounded-md border border-sky-300/70 bg-white/80 px-1.5 py-0.5 text-[11px] font-medium text-sky-900 dark:border-sky-500/40 dark:bg-background/40 dark:text-sky-100"
title={explicitWaitingResumeLabel}
>
<span className="text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80">
Resume
</span>
<span className="truncate max-w-[24rem]">{explicitWaitingResumeLabel}</span>
</span>,
);
}
return <div className="flex flex-wrap items-center gap-1.5 pt-0.5">{pills}</div>;
})();
const explicitWaitingJumpAnchor = (() => {
if (!showExplicitWaiting) return null;
let anchorId: string | null = null;
let label: string | null = null;
if (pendingInteraction) {
anchorId = `issue-thread-interaction-${pendingInteraction.id}`;
if (pendingInteraction.kind === "request_confirmation") label = "Jump to confirmation";
else if (pendingInteraction.kind === "ask_user_questions") label = "Jump to questions";
else if (pendingInteraction.kind === "suggest_tasks") label = "Jump to suggestions";
} else if (pendingApproval) {
anchorId = `issue-approval-${pendingApproval.id}`;
label = "Jump to approval";
}
if (!anchorId || !label) return null;
return (
<a
href={`#${anchorId}`}
className="inline-flex items-center gap-1 text-xs font-medium text-sky-700 hover:underline dark:text-sky-300"
>
{label} ↓
</a>
);
})();
const ownerPill = (() => {
if (!isRecoveryNeeded) return null;
const owner = blockerAttention?.nextActionOwner ?? null;
@@ -184,6 +530,8 @@ export function IssueBlockedNotice({
);
})();
void issueId; // currently unused; reserved for future deep-anchor wiring
return (
<div
data-blocker-attention-state={blockerAttention?.state}
@@ -191,10 +539,19 @@ export function IssueBlockedNotice({
className={containerClass}
>
<div className="flex items-start gap-2">
<AlertTriangle className={iconClass} />
{showExplicitWaiting ? (
<Hourglass className={iconClass} aria-hidden />
) : (
<AlertTriangle className={iconClass} />
)}
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{isRecoveryNeeded
{showExplicitWaiting ? (
<>
<strong>{explicitWaitingHeadline}</strong>
{explicitWaitingBody ? <> {explicitWaitingBody}</> : null}
</>
) : isRecoveryNeeded
? isLeafSurface
? recoveryLeafCopy(blockerAttention!.reason as RecoveryReason)
: recoveryParentCopy(
@@ -209,23 +566,35 @@ export function IssueBlockedNotice({
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{!isRecoveryNeeded && blockers.length > 0 ? (
{showExplicitWaiting ? (
<>
{explicitWaitingPills}
{isWaitChain && waitLeafBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Waiting at</span>
{waitLeafBlockers.map((blocker) => renderBlockerChip(blocker, "sky"))}
</div>
) : null}
{explicitWaitingJumpAnchor}
</>
) : null}
{!showExplicitWaiting && !isRecoveryNeeded && blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
</div>
) : null}
{isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
{!showExplicitWaiting && isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Liveness break at</span>
{recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
</div>
) : null}
{!isRecoveryNeeded && showStalledRow ? (
{!showExplicitWaiting && !isRecoveryNeeded && showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Stalled in review</span>
{stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
</div>
) : !isRecoveryNeeded && terminalBlockers.length > 0 ? (
) : !showExplicitWaiting && !isRecoveryNeeded && terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Ultimately waiting on</span>
{terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
+14
View File
@@ -36,6 +36,7 @@ import {
} from "@tanstack/react-virtual";
import type {
Agent,
Approval,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
@@ -252,8 +253,11 @@ interface IssueChatComposerProps {
}
interface IssueChatThreadProps {
issueId?: string | null;
issueIdentifier?: string | null;
comments: IssueChatComment[];
interactions?: IssueThreadInteraction[];
approvals?: Approval[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -2947,8 +2951,11 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
});
export function IssueChatThread({
issueId = null,
issueIdentifier = null,
comments,
interactions = [],
approvals = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -3514,9 +3521,16 @@ export function IssueChatThread({
{showComposer ? (
<div data-testid="issue-chat-thread-notices" className="space-y-2">
<IssueBlockedNotice
issueId={issueId}
issueIdentifier={issueIdentifier}
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
interactions={interactions}
approvals={approvals}
agentMap={agentMap}
userLabelMap={userLabelMap}
currentUserId={currentUserId}
/>
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>
+34
View File
@@ -227,6 +227,40 @@ describe("IssueRow", () => {
});
});
it("renders the sky waiting pill with the resolved owner label when blockerAttention.reason is explicit_waiting", () => {
const root = createRoot(container);
const issue = createIssue({
blockerAttention: {
state: "covered",
reason: "explicit_waiting",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
},
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
const pills = Array.from(container.querySelectorAll<HTMLSpanElement>("span"))
.filter(
(element) => element.textContent === "Waiting · board"
&& element.className.includes("border-sky-200/80"),
);
expect(pills.length).toBeGreaterThan(0);
expect(pills[0]?.getAttribute("title")).toBe("Waiting · board");
act(() => {
root.unmount();
});
});
it("renders without error when titleSuffix is omitted", () => {
const root = createRoot(container);
+65 -1
View File
@@ -7,6 +7,7 @@ import {
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
@@ -34,6 +35,37 @@ interface IssueRowProps {
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
agentNameMap?: ReadonlyMap<string, string> | null;
userLabelMap?: ReadonlyMap<string, string> | null;
currentUserId?: string | null;
}
function truncateOwnerLabel(label: string, max = 18): string {
if (label.length <= max) return label;
return `${label.slice(0, Math.max(0, max - 1))}…`;
}
function resolveWaitingOwnerLabel(
issue: Issue,
agentNameMap: IssueRowProps["agentNameMap"],
userLabelMap: IssueRowProps["userLabelMap"],
currentUserId: string | null | undefined,
): string | null {
const owner = issue.blockerAttention?.nextActionOwner ?? null;
if (!owner) return null;
if (owner.type === "user") {
if (!owner.userId) return "board";
const resolved = formatAssigneeUserLabel(owner.userId, currentUserId ?? null, userLabelMap ?? undefined);
return resolved ? truncateOwnerLabel(resolved) : null;
}
if (owner.type === "agent") {
if (owner.agentId && agentNameMap) {
const name = agentNameMap.get(owner.agentId);
if (name) return truncateOwnerLabel(name);
}
return null;
}
return null;
}
export function IssueRow({
@@ -57,6 +89,9 @@ export function IssueRow({
onArchive,
archiveDisabled,
className,
agentNameMap,
userLabelMap,
currentUserId,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
@@ -72,6 +107,29 @@ export function IssueRow({
const recoveryLeafLabel = recoveryLeafIdentifier && recoveryLeafIdentifier !== identifier
? `liveness break at ${recoveryLeafIdentifier}`
: null;
const isExplicitWaiting =
issue.blockerAttention?.state === "covered"
&& issue.blockerAttention?.reason === "explicit_waiting";
const waitingOwnerLabel = isExplicitWaiting
? resolveWaitingOwnerLabel(issue, agentNameMap, userLabelMap, currentUserId)
: null;
const waitingPillTitle = waitingOwnerLabel ? `Waiting · ${waitingOwnerLabel}` : "Waiting";
const desktopWaitingPill = isExplicitWaiting ? (
<span
className="inline-flex shrink-0 items-center rounded border border-sky-200/80 bg-sky-50 px-1.5 py-0.5 text-[11px] font-medium text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200"
title={waitingPillTitle}
>
{waitingPillTitle}
</span>
) : null;
const mobileWaitingPill = isExplicitWaiting ? (
<span
className="inline-flex shrink-0 items-center rounded border border-sky-200/80 bg-sky-50 px-1.5 py-0.5 text-[11px] font-medium text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200"
title={waitingPillTitle}
>
Waiting
</span>
) : null;
const productivityReviewIndicator = productivityReview ? (
<span
className={cn(
@@ -155,14 +213,20 @@ export function IssueRow({
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta) ? (
{(desktopTrailing || trailingMeta || desktopWaitingPill) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{desktopWaitingPill}
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{mobileWaitingPill ? (
<span className="ml-auto inline-flex shrink-0 items-center sm:hidden">
{mobileWaitingPill}
</span>
) : null}
{showUnreadSlot ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
+11
View File
@@ -620,6 +620,14 @@ export function IssuesList({
return agents.find((a) => a.id === id)?.name ?? null;
}, [agents]);
const agentNameById = useMemo(() => {
const map = new Map<string, string>();
for (const agent of agents ?? []) {
map.set(agent.id, agent.name);
}
return map;
}, [agents]);
const companyUserLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
@@ -1422,6 +1430,9 @@ export function IssuesList({
<IssueRow
issue={issue}
issueLinkState={issueLinkState}
agentNameMap={agentNameById}
userLabelMap={companyUserLabelMap}
currentUserId={currentUserId}
checklistStepNumber={checklistStepNumber}
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
checklistDependencyChips={checklistDependencyChips}
+50
View File
@@ -129,6 +129,56 @@ describe("StatusIcon", () => {
expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · automatic continuation exhausted"');
});
it("renders explicit_waiting issues with sky ring and waiting-on-board copy", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="in_progress"
blockerAttention={{
state: "covered",
reason: "explicit_waiting",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="explicit_waiting"');
expect(html).toContain('aria-label="Waiting on board"');
expect(html).toContain("border-sky-600");
expect(html).not.toContain("border-cyan-600");
expect(html).not.toContain("border-red-600");
expect(html).toContain("-bottom-0.5");
});
it("prefixes the explicit waiting tooltip with Blocked when the issue is also blocked", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "covered",
reason: "explicit_waiting",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-3000",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
nextActionHint: "needs_human_review",
}}
/>,
);
expect(html).toContain('aria-label="Blocked · waiting on user"');
expect(html).toContain("border-sky-600");
});
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
const html = renderToStaticMarkup(
<StatusIcon
+48 -17
View File
@@ -20,9 +20,25 @@ interface StatusIconProps {
showLabel?: boolean;
}
function explicitWaitingTooltipBase(blockerAttention: IssueBlockerAttention | null | undefined): string {
const owner = blockerAttention?.nextActionOwner ?? null;
if (!owner) return "Waiting on a decision";
if (owner.type === "user") {
return owner.userId ? "Waiting on user" : "Waiting on board";
}
if (owner.type === "agent") {
return "Waiting on agent";
}
return "Waiting on a decision";
}
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
if (blockerAttention.reason === "explicit_waiting") {
return explicitWaitingTooltipBase(blockerAttention);
}
if (blockerAttention.reason === "active_child") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
@@ -79,27 +95,42 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const isExplicitWaiting =
blockerAttention?.state === "covered"
&& blockerAttention?.reason === "explicit_waiting";
const isCoveredBlocked = status === "blocked"
&& blockerAttention?.state === "covered"
&& !isExplicitWaiting;
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed";
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: isStalledBlocked
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
: isRecoveryNeeded
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const colorClass = isExplicitWaiting
? "text-sky-600 border-sky-600 dark:text-sky-400 dark:border-sky-400"
: isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: isStalledBlocked
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
: isRecoveryNeeded
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const isDone = status === "done";
const explicitWaitingAriaLabel = isExplicitWaiting
? (status === "blocked"
? `Blocked · ${explicitWaitingTooltipBase(blockerAttention).toLowerCase()}`
: explicitWaitingTooltipBase(blockerAttention))
: null;
const recoveryAriaLabel = isRecoveryNeeded ? blockedAttentionLabel(blockerAttention) : null;
const ariaLabel = recoveryAriaLabel
const ariaLabel = explicitWaitingAriaLabel
?? recoveryAriaLabel
?? (status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status));
const blockerAttentionState = isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: isRecoveryNeeded
? "recovery_needed"
: undefined;
const blockerAttentionState = isExplicitWaiting
? "explicit_waiting"
: isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: isRecoveryNeeded
? "recovery_needed"
: undefined;
const circle = (
<span
@@ -116,7 +147,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
{isDone && (
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
)}
{isCoveredBlocked && (
{(isCoveredBlocked || isExplicitWaiting) && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
{isStalledBlocked && (
+3
View File
@@ -2156,6 +2156,9 @@ export function Inbox() {
issue={issue}
issueLinkState={issueLinkState}
selected={selected}
agentNameMap={agentById}
userLabelMap={companyUserLabelMap}
currentUserId={currentUserId}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
+5 -2
View File
@@ -27,6 +27,7 @@ const mockIssuesApi = vi.hoisted(() => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
upsertDocument: vi.fn(),
listApprovals: vi.fn(),
}));
const mockActivityApi = vi.hoisted(() => ({
@@ -782,6 +783,7 @@ describe("IssueDetail", () => {
mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() });
mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null });
mockIssuesApi.listTreeHolds.mockResolvedValue([]);
mockIssuesApi.listApprovals.mockResolvedValue([]);
mockActivityApi.forIssue.mockResolvedValue([]);
mockActivityApi.runsForIssue.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([]);
@@ -1173,8 +1175,9 @@ describe("IssueDetail", () => {
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Paused by board.");
});
expect(container.textContent).toContain("Paused by board.");
expect(container.textContent).toContain("in_review");
+12
View File
@@ -569,6 +569,7 @@ function InboxMobileToolbar({
type IssueDetailChatTabProps = {
issueId: string;
issueIdentifier: string | null;
companyId: string;
projectId: string | null;
issueStatus: Issue["status"];
@@ -625,6 +626,7 @@ type IssueDetailChatTabProps = {
const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId,
issueIdentifier,
companyId,
projectId,
issueStatus,
@@ -699,8 +701,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
refetchInterval: hasLiveRuns ? 5000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
});
const { data: linkedApprovals } = useQuery({
queryKey: queryKeys.issues.approvals(issueId),
queryFn: () => issuesApi.listApprovals(issueId),
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.listApprovals>>>(issueId),
});
const resolvedActivity = activity ?? [];
const resolvedLinkedRuns = linkedRuns ?? [];
const resolvedLinkedApprovals = linkedApprovals ?? [];
const runningIssueRun = useMemo(
() => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns),
@@ -818,8 +826,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
) : null}
<IssueChatThread
composerRef={composerRef}
issueId={issueId}
issueIdentifier={issueIdentifier}
comments={commentsWithRunMeta}
interactions={interactions}
approvals={resolvedLinkedApprovals}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
@@ -3556,6 +3567,7 @@ export function IssueDetail() {
{detailTab === "chat" ? (
<IssueDetailChatTab
issueId={issue.id}
issueIdentifier={issue.identifier ?? null}
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
@@ -248,6 +248,34 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
expectedVisual: "yellow ring + alert overlay",
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
},
{
label: "Explicit waiting (board confirmation)",
status: "in_review",
blockerAttention: attention({
state: "covered",
reason: "explicit_waiting",
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2708",
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}),
expectedVisual: "sky ring + small dot",
expectedCopy: "Waiting on board",
},
{
label: "Explicit waiting (specific user)",
status: "in_review",
blockerAttention: attention({
state: "covered",
reason: "explicit_waiting",
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2710",
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
nextActionHint: "needs_human_review",
}),
expectedVisual: "sky ring + small dot",
expectedCopy: "Waiting on user",
},
];
const coveredBlockedIssue = createIssue({
@@ -282,15 +310,22 @@ type BlockedNoticeStateLabel =
| "Stalled (single leaf)"
| "Stalled (multiple leaves)"
| "Recovery needed (parent perspective)"
| "Recovery needed (leaf perspective)";
| "Recovery needed (leaf perspective)"
| "Explicit wait (plan confirmation)"
| "Explicit wait (user response)"
| "Explicit wait (board approval)"
| "Explicit wait (chain — leaf elsewhere)";
type BlockedNoticeFixture = {
label: BlockedNoticeStateLabel;
caption: string;
issueStatus: string;
issueIdentifier?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention: IssueBlockerAttention;
ownerAgentName?: string;
interactions?: import("@paperclipai/shared").IssueThreadInteraction[];
approvals?: import("@paperclipai/shared").Approval[];
};
const stalledLeafSingle = summaryBlocker({
@@ -429,6 +464,134 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
}),
ownerAgentName: "CodexSparkCoder",
},
{
label: "Explicit wait (plan confirmation)",
caption: "A pending board confirmation against the latest plan revision — sky banner, target/owner/resume pills, jump anchor.",
issueStatus: "in_review",
issueIdentifier: "PAP-2708",
blockers: [],
blockerAttention: attention({
state: "covered",
reason: "explicit_waiting",
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2708",
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}),
interactions: [
{
id: "interaction-plan",
companyId: "co-1",
issueId: "issue-pap-2708",
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
createdAt: "2026-04-28T22:00:00.000Z",
updatedAt: "2026-04-28T22:00:00.000Z",
createdByAgentId: "agent-coder",
createdByUserId: null,
payload: {
version: 1,
prompt: "Approve plan",
target: {
type: "issue_document",
key: "plan",
revisionId: "rev-1",
revisionNumber: 2,
},
},
},
],
},
{
label: "Explicit wait (user response)",
caption: "Pending ask_user_questions waiting on a specific user — sky banner with target = '2 questions'.",
issueStatus: "in_review",
issueIdentifier: "PAP-2710",
blockers: [],
blockerAttention: attention({
state: "covered",
reason: "explicit_waiting",
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2710",
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
nextActionHint: "needs_human_review",
}),
interactions: [
{
id: "interaction-questions",
companyId: "co-1",
issueId: "issue-pap-2710",
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
createdAt: "2026-04-28T22:00:00.000Z",
updatedAt: "2026-04-28T22:00:00.000Z",
createdByAgentId: "agent-coder",
createdByUserId: null,
payload: {
version: 1,
questions: [
{ id: "q1", prompt: "Question 1?", selectionMode: "single", options: [] },
{ id: "q2", prompt: "Question 2?", selectionMode: "single", options: [] },
],
},
},
],
},
{
label: "Explicit wait (board approval)",
caption: "Pending board approval, no thread interaction — sky banner falls back to approval target/copy.",
issueStatus: "in_review",
issueIdentifier: "PAP-2711",
blockers: [],
blockerAttention: attention({
state: "covered",
reason: "explicit_waiting",
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2711",
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}),
approvals: [
{
id: "ca6ba09d-b558-4a53-a552-e7ef87e54a1b",
companyId: "co-1",
type: "request_board_approval",
requestedByAgentId: "agent-coder",
requestedByUserId: null,
status: "pending",
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-04-28T22:00:00.000Z"),
updatedAt: new Date("2026-04-28T22:00:00.000Z"),
},
],
},
{
label: "Explicit wait (chain — leaf elsewhere)",
caption: "Parent issue whose downstream leaf has the pending wait — sky banner with the 'Waiting at' chip strip.",
issueStatus: "blocked",
issueIdentifier: "PAP-2712",
blockers: [
summaryBlocker({
id: "issue-wait-chain-leaf",
identifier: "PAP-2708",
title: "Plan confirmation pending downstream",
status: "in_review",
}),
],
blockerAttention: attention({
state: "covered",
reason: "explicit_waiting",
coveredBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2708",
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
}),
},
];
function BlockedNoticeSurface({
@@ -453,10 +616,13 @@ function BlockedNoticeSurface({
</div>
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
<IssueBlockedNotice
issueIdentifier={fixture.issueIdentifier}
issueStatus={fixture.issueStatus}
blockers={fixture.blockers}
blockerAttention={fixture.blockerAttention}
ownerAgentName={fixture.ownerAgentName}
interactions={fixture.interactions}
approvals={fixture.approvals}
/>
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
</div>