- {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}