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