From 08a9e0caf04694da8824c838b8f8bd17f1f2603e Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Apr 2026 19:00:13 -0500 Subject: [PATCH] PAP-2731 Implement sky explicit-wait UI surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/components/IssueBlockedNotice.test.tsx | 104 +++++ ui/src/components/IssueBlockedNotice.tsx | 427 ++++++++++++++++-- ui/src/components/IssueChatThread.tsx | 14 + ui/src/components/IssueRow.test.tsx | 34 ++ ui/src/components/IssueRow.tsx | 66 ++- ui/src/components/IssuesList.tsx | 11 + ui/src/components/StatusIcon.test.tsx | 50 ++ ui/src/components/StatusIcon.tsx | 65 ++- ui/src/pages/Inbox.tsx | 3 + ui/src/pages/IssueDetail.test.tsx | 7 +- ui/src/pages/IssueDetail.tsx | 12 + .../stories/status-language.stories.tsx | 168 ++++++- 12 files changed, 911 insertions(+), 50 deletions(-) diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 24cee80ac6b..e41f1eac20a 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -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( + <> + + , + ); + + 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( + <> + + , + ); + + 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( <> diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index 761dcc9742e..c3ea5cf2f9f 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -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 = 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 | null; + userLabelMap?: ReadonlyMap | 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 = { + 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 = { + 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 ( {blocker.identifier ?? blocker.id.slice(0, 8)} - {blocker.title} + {blocker.title} ); }; @@ -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 + ? [{waitLeafIdentifier}] + : 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( + + + Target + + {explicitWaitingTargetLabel} + , + ); + } + pills.push( + + + Owner + + {ownerLabel} + , + ); + if (explicitWaitingResumeLabel) { + pills.push( + + + Resume + + {explicitWaitingResumeLabel} + , + ); + } + return
{pills}
; + })(); + + 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 ( + + {label} ↓ + + ); + })(); + 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 (
- + {showExplicitWaiting ? ( + + ) : ( + + )}

- {isRecoveryNeeded + {showExplicitWaiting ? ( + <> + {explicitWaitingHeadline} + {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.}

- {!isRecoveryNeeded && blockers.length > 0 ? ( + {showExplicitWaiting ? ( + <> + {explicitWaitingPills} + {isWaitChain && waitLeafBlockers.length > 0 ? ( +
+ Waiting at + {waitLeafBlockers.map((blocker) => renderBlockerChip(blocker, "sky"))} +
+ ) : null} + {explicitWaitingJumpAnchor} + + ) : null} + {!showExplicitWaiting && !isRecoveryNeeded && blockers.length > 0 ? (
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
) : null} - {isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? ( + {!showExplicitWaiting && isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
Liveness break at {recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
) : null} - {!isRecoveryNeeded && showStalledRow ? ( + {!showExplicitWaiting && !isRecoveryNeeded && showStalledRow ? (
Stalled in review {stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
- ) : !isRecoveryNeeded && terminalBlockers.length > 0 ? ( + ) : !showExplicitWaiting && !isRecoveryNeeded && terminalBlockers.length > 0 ? (
Ultimately waiting on {terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index d3d0dd636e4..1b3633bf464 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 944712e64c9..4250065584d 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -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(); + }); + + const pills = Array.from(container.querySelectorAll("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); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 81aa5b95991..bd8b98b8092 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -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 | null; + userLabelMap?: ReadonlyMap | 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 ? ( + + {waitingPillTitle} + + ) : null; + const mobileWaitingPill = isExplicitWaiting ? ( + + Waiting + + ) : null; const productivityReviewIndicator = productivityReview ? ( - {(desktopTrailing || trailingMeta) ? ( + {(desktopTrailing || trailingMeta || desktopWaitingPill) ? ( + {desktopWaitingPill} {desktopTrailing} {trailingMeta ? ( {trailingMeta} ) : null} ) : null} + {mobileWaitingPill ? ( + + {mobileWaitingPill} + + ) : null} {showUnreadSlot ? ( {showUnreadDot ? ( diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index d282aded27a..198c20147d9 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -620,6 +620,14 @@ export function IssuesList({ return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); + const agentNameById = useMemo(() => { + const map = new Map(); + 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({ { 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( + , + ); + + 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( + , + ); + + 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( )} - {isCoveredBlocked && ( + {(isCoveredBlocked || isExplicitWaiting) && ( )} {isStalledBlocked && ( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index cf4675fe129..59211346cc6 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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" diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 32da5890d50..8269f7e9efe 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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", () => { , ); }); - 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"); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b67e3141b4b..effd074b254 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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(issueId), }); + const { data: linkedApprovals } = useQuery({ + queryKey: queryKeys.issues.approvals(issueId), + queryFn: () => issuesApi.listApprovals(issueId), + placeholderData: keepPreviousDataForSameQueryTail>>(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}

{fixture.caption}