PAP-2789 Simplify waiting banner Owner/Resume rows and fix Jump anchor

The explicit-wait IssueBlockedNotice rendered Target/Owner/Resume as a row
of pills. Drop the Target pill entirely and render the remaining Owner and
Resume entries as a plain dl/dt/dd block — the value matters more than the
chip styling for these long-form details.

The "Jump to questions/confirmation/approval" affordance referenced anchor
ids that did not exist in the DOM (`issue-thread-interaction-{id}` and
`issue-approval-{id}`); the actual anchors are `interaction-{id}` and
`approval-{id}`. Switch to those ids and use a button with
scrollIntoView({behavior:"smooth"}) so the jump actually scrolls to the
target.

Add a small ExplicitWaitingJumpAnchor component that flips the arrow ↑/↓
based on whether the target is above or below the banner, recomputing on
scroll/resize.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 22:34:52 -05:00
1 parent bcc88b561a
commit ffe319a3f7
3 files changed
+76 -78

No files matched your search

@@ -147,7 +147,10 @@ describe("IssueBlockedNotice", () => {
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).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");
@@ -196,7 +199,7 @@ describe("IssueBlockedNotice", () => {
expect(html).toContain("Waiting on board approval");
expect(html).toContain("A board approval is open");
expect(html).toContain("Approval approval");
expect(html).not.toContain("Approval approval");
expect(html).toContain("Jump to approval");
expect(html).toContain("border-sky-300/70");
});
+68 -73
View File
@@ -8,7 +8,8 @@ import type {
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@paperclipai/shared";
import { AlertTriangle, Hourglass } from "lucide-react";
import { AlertTriangle, ArrowDown, ArrowUp, Hourglass } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
@@ -378,38 +379,6 @@ export function IssueBlockedNotice({
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";
@@ -432,37 +401,24 @@ export function IssueBlockedNotice({
return null;
})();
const explicitWaitingPills = (() => {
const explicitWaitingDetails = (() => {
if (!showExplicitWaiting) return null;
const pillClass =
"inline-flex max-w-full 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";
const labelClass =
"shrink-0 text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80";
const valueClass = "min-w-0 break-words";
const pills: React.ReactNode[] = [];
if (explicitWaitingTargetLabel) {
pills.push(
<span key="target" className={pillClass}>
<span className={labelClass}>Target</span>
<span className={valueClass}>{explicitWaitingTargetLabel}</span>
</span>,
);
}
pills.push(
<span key="owner" className={pillClass}>
<span className={labelClass}>Owner</span>
<span className={valueClass}>{ownerLabel}</span>
</span>,
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>
);
if (explicitWaitingResumeLabel) {
pills.push(
<span key="resume" className={pillClass} title={explicitWaitingResumeLabel}>
<span className={labelClass}>Resume</span>
<span className={valueClass}>{explicitWaitingResumeLabel}</span>
</span>,
);
}
return <div className="flex flex-wrap items-center gap-1.5 pt-0.5">{pills}</div>;
})();
const explicitWaitingJumpAnchor = (() => {
@@ -470,23 +426,16 @@ export function IssueBlockedNotice({
let anchorId: string | null = null;
let label: string | null = null;
if (pendingInteraction) {
anchorId = `issue-thread-interaction-${pendingInteraction.id}`;
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 = `issue-approval-${pendingApproval.id}`;
anchorId = `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>
);
return <ExplicitWaitingJumpAnchor anchorId={anchorId} label={label} />;
})();
const ownerPill = (() => {
@@ -557,7 +506,7 @@ export function IssueBlockedNotice({
</p>
{showExplicitWaiting ? (
<>
{explicitWaitingPills}
{explicitWaitingDetails}
{isWaitChain && waitLeafBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Waiting at</span>
@@ -595,3 +544,49 @@ 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>
);
}
@@ -466,7 +466,7 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
},
{
label: "Explicit wait (plan confirmation)",
caption: "A pending board confirmation against the latest plan revision — sky banner, target/owner/resume pills, jump anchor.",
caption: "A pending board confirmation against the latest plan revision — sky banner, owner/resume rows, jump anchor.",
issueStatus: "in_review",
issueIdentifier: "PAP-2708",
blockers: [],
@@ -505,7 +505,7 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
},
{
label: "Explicit wait (user response)",
caption: "Pending ask_user_questions waiting on a specific user — sky banner with target = '2 questions'.",
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: [],
@@ -541,7 +541,7 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
},
{
label: "Explicit wait (board approval)",
caption: "Pending board approval, no thread interaction — sky banner falls back to approval target/copy.",
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: [],