Revert "PAP-2731 Implement sky explicit-wait UI surfaces"

This reverts commit 08a9e0caf0.
This commit is contained in:
Dotta committed 2026-04-29 16:02:11 -05:00
1 parent 0a345e9648
commit caf37a355d
43 files changed
+181 -4777

No files matched your search

+1 -1
View File
@@ -60,7 +60,7 @@ export interface LiveRunForIssue {
export interface WatchdogDecisionInput {
runId: string;
decision: "snooze" | "continue" | "dismissed_false_positive" | "cancel_run";
decision: "snooze" | "continue" | "dismissed_false_positive";
evaluationIssueId?: string | null;
reason?: string | null;
snoozedUntil?: string | null;
@@ -97,113 +97,6 @@ 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).not.toContain("Plan revision r2");
expect(html).not.toMatch(/>\s*Target\s*</);
expect(html).toContain(">Owner<");
expect(html).toContain(">Resume<");
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).not.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(
<>
+29 -382
View File
@@ -1,17 +1,6 @@
import type {
Approval,
AskUserQuestionsInteraction,
AskUserQuestionsPayload,
IssueBlockerAttention,
IssueRelationIssueSummary,
IssueThreadInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@paperclipai/shared";
import { AlertTriangle, ArrowDown, ArrowUp, Hourglass } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { Identity } from "./Identity";
@@ -33,94 +22,23 @@ 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);
const isExplicitWaiting = blockerAttention?.state === "covered"
&& blockerAttention.reason === "explicit_waiting";
if (
blockers.length === 0
&& issueStatus !== "blocked"
&& !isRecoveryNeeded
&& !isExplicitWaiting
) return null;
if (blockers.length === 0 && issueStatus !== "blocked" && !isRecoveryNeeded) return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -168,79 +86,37 @@ 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";
// 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 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";
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 renderBlockerChip = (blocker: IssueRelationIssueSummary, variant: "amber" | "rose" = "amber") => {
const issuePathId = blocker.identifier ?? blocker.id;
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",
};
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";
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className={chipClassByVariant[variant]}
className={chipClass}
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className={labelClassByVariant[variant]}>{blocker.title}</span>
<span className={labelClass}>{blocker.title}</span>
</IssueLinkQuicklook>
);
};
@@ -278,166 +154,6 @@ 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 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 explicitWaitingDetails = (() => {
if (!showExplicitWaiting) return null;
const labelClass = "shrink-0 text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80";
return (
<dl className="space-y-0.5 pt-0.5 text-xs text-sky-900 dark:text-sky-100">
<div className="flex flex-wrap items-baseline gap-1.5">
<dt className={labelClass}>Owner</dt>
<dd className="min-w-0 break-words">{ownerLabel}</dd>
</div>
{explicitWaitingResumeLabel ? (
<div className="flex flex-wrap items-baseline gap-1.5">
<dt className={labelClass}>Resume</dt>
<dd className="min-w-0 break-words">{explicitWaitingResumeLabel}</dd>
</div>
) : null}
</dl>
);
})();
const explicitWaitingJumpAnchor = (() => {
if (!showExplicitWaiting) return null;
let anchorId: string | null = null;
let label: string | null = null;
if (pendingInteraction) {
anchorId = `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 = `approval-${pendingApproval.id}`;
label = "Jump to approval";
}
if (!anchorId || !label) return null;
return <ExplicitWaitingJumpAnchor anchorId={anchorId} label={label} />;
})();
const ownerPill = (() => {
if (!isRecoveryNeeded) return null;
const owner = blockerAttention?.nextActionOwner ?? null;
@@ -468,8 +184,6 @@ export function IssueBlockedNotice({
);
})();
void issueId; // currently unused; reserved for future deep-anchor wiring
return (
<div
data-blocker-attention-state={blockerAttention?.state}
@@ -477,19 +191,10 @@ export function IssueBlockedNotice({
className={containerClass}
>
<div className="flex items-start gap-2">
{showExplicitWaiting ? (
<Hourglass className={iconClass} aria-hidden />
) : (
<AlertTriangle className={iconClass} />
)}
<AlertTriangle className={iconClass} />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{showExplicitWaiting ? (
<>
<strong>{explicitWaitingHeadline}</strong>
{explicitWaitingBody ? <> {explicitWaitingBody}</> : null}
</>
) : isRecoveryNeeded
{isRecoveryNeeded
? isLeafSurface
? recoveryLeafCopy(blockerAttention!.reason as RecoveryReason)
: recoveryParentCopy(
@@ -504,35 +209,23 @@ 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>
{showExplicitWaiting ? (
<>
{explicitWaitingDetails}
{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 ? (
{!isRecoveryNeeded && blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
</div>
) : null}
{!showExplicitWaiting && isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
{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}
{!showExplicitWaiting && !isRecoveryNeeded && showStalledRow ? (
{!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>
) : !showExplicitWaiting && !isRecoveryNeeded && terminalBlockers.length > 0 ? (
) : !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"))}
@@ -544,49 +237,3 @@ export function IssueBlockedNotice({
</div>
);
}
function ExplicitWaitingJumpAnchor({ anchorId, label }: { anchorId: string; label: string }) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [direction, setDirection] = useState<"up" | "down">("down");
useEffect(() => {
const button = buttonRef.current;
if (!button) return;
const update = () => {
const target = document.getElementById(anchorId);
if (!target) return;
const targetRect = target.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const targetMid = targetRect.top + targetRect.height / 2;
const buttonMid = buttonRect.top + buttonRect.height / 2;
setDirection(targetMid < buttonMid ? "up" : "down");
};
update();
window.addEventListener("scroll", update, { passive: true, capture: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, { capture: true } as EventListenerOptions);
window.removeEventListener("resize", update);
};
}, [anchorId]);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const target = document.getElementById(anchorId);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
};
const Arrow = direction === "up" ? ArrowUp : ArrowDown;
return (
<button
type="button"
ref={buttonRef}
onClick={handleClick}
className="inline-flex items-center gap-1 text-xs font-medium text-sky-700 hover:underline dark:text-sky-300"
>
{label}
<Arrow className="h-3 w-3" aria-hidden />
</button>
);
}
-14
View File
@@ -36,7 +36,6 @@ import {
} from "@tanstack/react-virtual";
import type {
Agent,
Approval,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
@@ -256,11 +255,8 @@ interface IssueChatComposerProps {
}
interface IssueChatThreadProps {
issueId?: string | null;
issueIdentifier?: string | null;
comments: IssueChatComment[];
interactions?: IssueThreadInteraction[];
approvals?: Approval[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -2961,11 +2957,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
});
export function IssueChatThread({
issueId = null,
issueIdentifier = null,
comments,
interactions = [],
approvals = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -3535,16 +3528,9 @@ 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,40 +227,6 @@ 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);
+1 -65
View File
@@ -7,7 +7,6 @@ import {
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
@@ -35,37 +34,6 @@ 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({
@@ -89,9 +57,6 @@ 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);
@@ -107,29 +72,6 @@ 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(
@@ -213,20 +155,14 @@ export function IssueRow({
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta || desktopWaitingPill) ? (
{(desktopTrailing || trailingMeta) ? (
<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 gap-1.5 sm:hidden">
{mobileWaitingPill}
</span>
) : null}
{showUnreadSlot ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
-16
View File
@@ -411,7 +411,6 @@ describe("IssueRunLedger", () => {
expect(container.textContent).toContain("Stale-run watchdog alert");
expect(container.textContent).toContain("PAP-404");
expect(container.textContent).toContain("Stale run");
expect(container.textContent).toContain("assigned recovery owner can cancel this bound run");
const watchdogBanner = Array.from(container.querySelectorAll("p"))
.find((node) => node.textContent?.includes("Stale-run watchdog alert"))
?.closest("div");
@@ -430,20 +429,6 @@ describe("IssueRunLedger", () => {
decision: "continue",
evaluationIssueId: "issue-eval-1",
});
const cancelButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Cancel run"),
);
expect(cancelButton).not.toBeUndefined();
act(() => {
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onWatchdogDecision).toHaveBeenCalledWith({
runId: "run-live-1",
decision: "cancel_run",
evaluationIssueId: "issue-eval-1",
reason: "Cancelled from stale-run recovery banner",
});
});
it("hides watchdog decision actions for known non-owner viewers", () => {
@@ -460,7 +445,6 @@ describe("IssueRunLedger", () => {
expect(container.textContent).not.toContain("Continue monitoring");
expect(container.textContent).not.toContain("Snooze 1h");
expect(container.textContent).not.toContain("Mark false positive");
expect(container.textContent).not.toContain("Cancel run");
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(onWatchdogDecision).not.toHaveBeenCalled();
});
-19
View File
@@ -575,9 +575,6 @@ export function IssueRunLedgerContent({
{" "}for recovery context.
</>
) : null}
{latestSilentRun.outputSilence.level === "critical" ? (
<> The assigned recovery owner can cancel this bound run if the evidence shows it is stuck.</>
) : null}
</p>
{onWatchdogDecision && canRecordWatchdogDecisions ? (
<div className="mt-2 flex flex-wrap gap-1.5">
@@ -623,22 +620,6 @@ export function IssueRunLedgerContent({
>
Mark false positive
</button>
{latestSilentRun.outputSilence.level === "critical" ? (
<button
type="button"
className="rounded-md border border-red-500/40 bg-red-500/10 px-2 py-1 text-[11px] text-red-900 hover:bg-red-500/15 dark:text-red-200"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "cancel_run",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
reason: "Cancelled from stale-run recovery banner",
})}
disabled={pendingWatchdogDecision != null}
>
Cancel run
</button>
) : null}
</div>
) : null}
{watchdogDecisionError ? (
-11
View File
@@ -665,14 +665,6 @@ 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],
@@ -1481,9 +1473,6 @@ 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,56 +129,6 @@ 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
+17 -48
View File
@@ -20,25 +20,9 @@ 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) {
@@ -95,42 +79,27 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const isExplicitWaiting =
blockerAttention?.state === "covered"
&& blockerAttention?.reason === "explicit_waiting";
const isCoveredBlocked = status === "blocked"
&& blockerAttention?.state === "covered"
&& !isExplicitWaiting;
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed";
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 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 isDone = status === "done";
const explicitWaitingAriaLabel = isExplicitWaiting
? (status === "blocked"
? `Blocked · ${explicitWaitingTooltipBase(blockerAttention).toLowerCase()}`
: explicitWaitingTooltipBase(blockerAttention))
: null;
const recoveryAriaLabel = isRecoveryNeeded ? blockedAttentionLabel(blockerAttention) : null;
const ariaLabel = explicitWaitingAriaLabel
?? recoveryAriaLabel
const ariaLabel = recoveryAriaLabel
?? (status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status));
const blockerAttentionState = isExplicitWaiting
? "explicit_waiting"
: isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: isRecoveryNeeded
? "recovery_needed"
: undefined;
const blockerAttentionState = isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: isRecoveryNeeded
? "recovery_needed"
: undefined;
const circle = (
<span
@@ -147,7 +116,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 || isExplicitWaiting) && (
{isCoveredBlocked && (
<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,9 +2156,6 @@ 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"
+2 -5
View File
@@ -27,7 +27,6 @@ const mockIssuesApi = vi.hoisted(() => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
upsertDocument: vi.fn(),
listApprovals: vi.fn(),
}));
const mockActivityApi = vi.hoisted(() => ({
@@ -783,7 +782,6 @@ 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([]);
@@ -1175,9 +1173,8 @@ describe("IssueDetail", () => {
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(container.textContent).toContain("Paused by board.");
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Paused by board.");
expect(container.textContent).toContain("in_review");
-12
View File
@@ -570,7 +570,6 @@ function InboxMobileToolbar({
type IssueDetailChatTabProps = {
issueId: string;
issueIdentifier: string | null;
companyId: string;
projectId: string | null;
issueStatus: Issue["status"];
@@ -628,7 +627,6 @@ type IssueDetailChatTabProps = {
const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId,
issueIdentifier,
companyId,
projectId,
issueStatus,
@@ -704,14 +702,8 @@ 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),
@@ -829,11 +821,8 @@ 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}
@@ -3630,7 +3619,6 @@ export function IssueDetail() {
{detailTab === "chat" ? (
<IssueDetailChatTab
issueId={issue.id}
issueIdentifier={issue.identifier ?? null}
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
@@ -248,34 +248,6 @@ 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({
@@ -310,22 +282,15 @@ type BlockedNoticeStateLabel =
| "Stalled (single leaf)"
| "Stalled (multiple leaves)"
| "Recovery needed (parent perspective)"
| "Recovery needed (leaf perspective)"
| "Explicit wait (plan confirmation)"
| "Explicit wait (user response)"
| "Explicit wait (board approval)"
| "Explicit wait (chain — leaf elsewhere)";
| "Recovery needed (leaf perspective)";
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({
@@ -464,134 +429,6 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
}),
ownerAgentName: "CodexSparkCoder",
},
{
label: "Explicit wait (plan confirmation)",
caption: "A pending board confirmation against the latest plan revision — sky banner, owner/resume rows, 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 owner/resume rows and a Jump to questions anchor.",
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 board-approval headline and Jump to approval anchor.",
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({
@@ -616,13 +453,10 @@ 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>