mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Revert "PAP-2731 Implement sky explicit-wait UI surfaces"
This reverts commit 08a9e0caf0.
This commit is contained in:
1 parent
0a345e9648
commit
caf37a355d
43 files changed
+181
-4777
No files matched your search
@@ -60,7 +60,7 @@ export interface LiveRunForIssue {
|
||||
|
||||
export interface WatchdogDecisionInput {
|
||||
runId: string;
|
||||
decision: "snooze" | "continue" | "dismissed_false_positive" | "cancel_run";
|
||||
decision: "snooze" | "continue" | "dismissed_false_positive";
|
||||
evaluationIssueId?: string | null;
|
||||
reason?: string | null;
|
||||
snoozedUntil?: string | null;
|
||||
|
||||
@@ -97,113 +97,6 @@ describe("IssueBlockedNotice", () => {
|
||||
expect(html).toContain("Wake to continue");
|
||||
});
|
||||
|
||||
it("renders the sky explicit-wait banner with plan-confirmation copy when a pending request_confirmation exists", () => {
|
||||
const html = renderNotice(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueIdentifier="PAP-2708"
|
||||
issueStatus="in_review"
|
||||
blockers={[]}
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
interactions={[
|
||||
{
|
||||
id: "interaction-1",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-28T22:00:00.000Z",
|
||||
updatedAt: "2026-04-28T22:00:00.000Z",
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByUserId: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve plan",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "rev-1",
|
||||
revisionNumber: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="covered"');
|
||||
expect(html).toContain('data-blocker-attention-reason="explicit_waiting"');
|
||||
expect(html).toContain("Waiting on board confirmation");
|
||||
expect(html).not.toContain("Plan revision r2");
|
||||
expect(html).not.toMatch(/>\s*Target\s*</);
|
||||
expect(html).toContain(">Owner<");
|
||||
expect(html).toContain(">Resume<");
|
||||
expect(html).toContain("Resumes when the board accepts");
|
||||
expect(html).toContain("border-sky-300/70");
|
||||
expect(html).not.toContain("border-rose-300/70");
|
||||
expect(html).toContain("Jump to confirmation");
|
||||
});
|
||||
|
||||
it("renders the sky explicit-wait banner falling back to a board-approval message when only an approval is pending", () => {
|
||||
const html = renderNotice(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueIdentifier="PAP-2710"
|
||||
issueStatus="in_review"
|
||||
blockers={[]}
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2710",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
interactions={[]}
|
||||
approvals={[
|
||||
{
|
||||
id: "approval-abcdef12",
|
||||
companyId: "co-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Waiting on board approval");
|
||||
expect(html).toContain("A board approval is open");
|
||||
expect(html).not.toContain("Approval approval");
|
||||
expect(html).toContain("Jump to approval");
|
||||
expect(html).toContain("border-sky-300/70");
|
||||
});
|
||||
|
||||
it("falls back to amber stalled treatment when state is stalled rather than recovery_needed", () => {
|
||||
const html = renderNotice(
|
||||
<>
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import type {
|
||||
Approval,
|
||||
AskUserQuestionsInteraction,
|
||||
AskUserQuestionsPayload,
|
||||
IssueBlockerAttention,
|
||||
IssueRelationIssueSummary,
|
||||
IssueThreadInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, Hourglass } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { Identity } from "./Identity";
|
||||
|
||||
@@ -33,94 +22,23 @@ const NEXT_ACTION_VERB: Record<NonNullable<IssueBlockerAttention["nextActionHint
|
||||
reassign: "Reassign",
|
||||
};
|
||||
|
||||
type WaitingActionableInteraction =
|
||||
| RequestConfirmationInteraction
|
||||
| AskUserQuestionsInteraction
|
||||
| SuggestTasksInteraction;
|
||||
|
||||
const WAITING_INTERACTION_KINDS: ReadonlySet<string> = new Set([
|
||||
"request_confirmation",
|
||||
"ask_user_questions",
|
||||
"suggest_tasks",
|
||||
]);
|
||||
|
||||
function isWaitingActionableInteraction(
|
||||
interaction: IssueThreadInteraction,
|
||||
): interaction is WaitingActionableInteraction {
|
||||
return WAITING_INTERACTION_KINDS.has(interaction.kind);
|
||||
}
|
||||
|
||||
function pickLatestPendingInteraction(
|
||||
interactions: IssueThreadInteraction[] | null | undefined,
|
||||
): WaitingActionableInteraction | null {
|
||||
if (!interactions || interactions.length === 0) return null;
|
||||
let best: WaitingActionableInteraction | null = null;
|
||||
let bestTime = -Infinity;
|
||||
for (const interaction of interactions) {
|
||||
if (interaction.status !== "pending") continue;
|
||||
if (!isWaitingActionableInteraction(interaction)) continue;
|
||||
const time = new Date(interaction.createdAt as string | Date).getTime();
|
||||
if (Number.isFinite(time) && time > bestTime) {
|
||||
bestTime = time;
|
||||
best = interaction;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function pickLatestPendingApproval(approvals: Approval[] | null | undefined): Approval | null {
|
||||
if (!approvals || approvals.length === 0) return null;
|
||||
let best: Approval | null = null;
|
||||
let bestTime = -Infinity;
|
||||
for (const approval of approvals) {
|
||||
if (approval.status !== "pending") continue;
|
||||
const time = new Date(approval.createdAt as string | Date).getTime();
|
||||
if (Number.isFinite(time) && time > bestTime) {
|
||||
bestTime = time;
|
||||
best = approval;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueId,
|
||||
issueIdentifier,
|
||||
issueStatus,
|
||||
blockers,
|
||||
blockerAttention,
|
||||
ownerAgentName,
|
||||
ownerUserName,
|
||||
interactions,
|
||||
approvals,
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
currentUserId,
|
||||
}: {
|
||||
issueId?: string | null;
|
||||
issueIdentifier?: string | null;
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
ownerAgentName?: string | null;
|
||||
ownerUserName?: string | null;
|
||||
interactions?: IssueThreadInteraction[] | null;
|
||||
approvals?: Approval[] | null;
|
||||
agentMap?: ReadonlyMap<string, { name: string }> | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
currentUserId?: string | null;
|
||||
}) {
|
||||
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed"
|
||||
&& blockerAttention.reason !== null
|
||||
&& RECOVERY_REASONS.has(blockerAttention.reason);
|
||||
const isExplicitWaiting = blockerAttention?.state === "covered"
|
||||
&& blockerAttention.reason === "explicit_waiting";
|
||||
if (
|
||||
blockers.length === 0
|
||||
&& issueStatus !== "blocked"
|
||||
&& !isRecoveryNeeded
|
||||
&& !isExplicitWaiting
|
||||
) return null;
|
||||
if (blockers.length === 0 && issueStatus !== "blocked" && !isRecoveryNeeded) return null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
@@ -168,79 +86,37 @@ export function IssueBlockedNotice({
|
||||
}
|
||||
return candidates;
|
||||
})();
|
||||
// Leaf-level surface: the issue itself is the invalid leaf. We render the
|
||||
// rose notice without any blocker chips, using the leaf-perspective copy.
|
||||
const isLeafSurface = isRecoveryNeeded && issueStatus !== "blocked";
|
||||
|
||||
// Explicit-wait wins over rose only when state is actually "covered". The
|
||||
// server already gates these so they cannot co-occur on the same issue (rose
|
||||
// wins on tie via the precedence: recovery_needed first, then explicit
|
||||
// waiting), but check explicitly per the spec.
|
||||
const showExplicitWaiting = isExplicitWaiting && !isRecoveryNeeded;
|
||||
const waitLeafIdentifier = showExplicitWaiting
|
||||
? blockerAttention?.sampleBlockerIdentifier ?? null
|
||||
: null;
|
||||
const isWaitChain = showExplicitWaiting
|
||||
&& waitLeafIdentifier !== null
|
||||
&& issueIdentifier != null
|
||||
&& waitLeafIdentifier !== issueIdentifier;
|
||||
const waitLeafBlockers = (() => {
|
||||
if (!showExplicitWaiting || !isWaitChain) return [] as IssueRelationIssueSummary[];
|
||||
const candidates: IssueRelationIssueSummary[] = [];
|
||||
for (const blocker of [...blockers, ...terminalBlockers]) {
|
||||
if (blocker.status === "done" || blocker.status === "cancelled") continue;
|
||||
if (candidates.some((existing) => existing.id === blocker.id)) continue;
|
||||
candidates.push(blocker);
|
||||
}
|
||||
if (waitLeafIdentifier) {
|
||||
const preferred = candidates.find(
|
||||
(blocker) => (blocker.identifier ?? blocker.id) === waitLeafIdentifier,
|
||||
);
|
||||
if (preferred) {
|
||||
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
})();
|
||||
const containerClass = isRecoveryNeeded
|
||||
? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100"
|
||||
: "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100";
|
||||
const iconClass = isRecoveryNeeded
|
||||
? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300"
|
||||
: "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300";
|
||||
const subRowLabelClass = isRecoveryNeeded
|
||||
? "text-xs font-medium text-rose-800 dark:text-rose-200"
|
||||
: "text-xs font-medium text-amber-800 dark:text-amber-200";
|
||||
|
||||
const containerClass = showExplicitWaiting
|
||||
? "mb-3 rounded-md border border-sky-300/70 bg-sky-50/90 px-3 py-2.5 text-sm text-sky-950 shadow-sm dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100"
|
||||
: isRecoveryNeeded
|
||||
? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100"
|
||||
: "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100";
|
||||
const iconClass = showExplicitWaiting
|
||||
? "mt-0.5 h-4 w-4 shrink-0 text-sky-600 dark:text-sky-300"
|
||||
: isRecoveryNeeded
|
||||
? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300"
|
||||
: "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300";
|
||||
const subRowLabelClass = showExplicitWaiting
|
||||
? "text-xs font-medium text-sky-800 dark:text-sky-200"
|
||||
: isRecoveryNeeded
|
||||
? "text-xs font-medium text-rose-800 dark:text-rose-200"
|
||||
: "text-xs font-medium text-amber-800 dark:text-amber-200";
|
||||
|
||||
const renderBlockerChip = (
|
||||
blocker: IssueRelationIssueSummary,
|
||||
variant: "amber" | "rose" | "sky" = "amber",
|
||||
) => {
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary, variant: "amber" | "rose" = "amber") => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
const chipClassByVariant: Record<typeof variant, string> = {
|
||||
amber: "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15",
|
||||
rose: "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15",
|
||||
sky: "inline-flex max-w-full items-center gap-1 rounded-md border border-sky-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-sky-950 transition-colors hover:border-sky-500 hover:bg-sky-100 hover:underline dark:border-sky-500/40 dark:bg-background/40 dark:text-sky-100 dark:hover:bg-sky-500/15",
|
||||
};
|
||||
const labelClassByVariant: Record<typeof variant, string> = {
|
||||
amber: "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200",
|
||||
rose: "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200",
|
||||
sky: "max-w-[18rem] truncate font-sans text-[11px] text-sky-800 dark:text-sky-200",
|
||||
};
|
||||
const chipClass = variant === "rose"
|
||||
? "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15"
|
||||
: "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15";
|
||||
const labelClass = variant === "rose"
|
||||
? "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200"
|
||||
: "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200";
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
issuePathId={issuePathId}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
className={chipClassByVariant[variant]}
|
||||
className={chipClass}
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className={labelClassByVariant[variant]}>{blocker.title}</span>
|
||||
<span className={labelClass}>{blocker.title}</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
@@ -278,166 +154,6 @@ export function IssueBlockedNotice({
|
||||
);
|
||||
};
|
||||
|
||||
// ----- Explicit waiting helpers -----
|
||||
|
||||
const ownerLabel = (() => {
|
||||
const owner = blockerAttention?.nextActionOwner ?? null;
|
||||
if (!owner) return "the next reviewer";
|
||||
if (owner.type === "user") {
|
||||
if (!owner.userId) return "the board";
|
||||
const resolved = formatAssigneeUserLabel(owner.userId, currentUserId ?? null, userLabelMap ?? undefined);
|
||||
if (resolved) return resolved;
|
||||
return ownerUserName ?? "the user";
|
||||
}
|
||||
if (owner.type === "agent") {
|
||||
if (owner.agentId && agentMap) {
|
||||
const agent = agentMap.get(owner.agentId);
|
||||
if (agent?.name) return agent.name;
|
||||
}
|
||||
return ownerAgentName ?? "the agent";
|
||||
}
|
||||
return "the next reviewer";
|
||||
})();
|
||||
|
||||
const pendingInteraction = showExplicitWaiting && !isWaitChain
|
||||
? pickLatestPendingInteraction(interactions ?? null)
|
||||
: null;
|
||||
const pendingApproval = showExplicitWaiting && !pendingInteraction && !isWaitChain
|
||||
? pickLatestPendingApproval(approvals ?? null)
|
||||
: null;
|
||||
|
||||
const isPlanConfirmation = (interaction: WaitingActionableInteraction): boolean => {
|
||||
if (interaction.kind !== "request_confirmation") return false;
|
||||
const target = interaction.payload?.target ?? null;
|
||||
if (!target) return false;
|
||||
if (target.type === "issue_document") return target.key === "plan";
|
||||
return false;
|
||||
};
|
||||
|
||||
const explicitWaitingHeadline = (() => {
|
||||
if (!showExplicitWaiting) return null;
|
||||
if (isWaitChain) {
|
||||
return <>Waiting on {ownerLabel} downstream.</>;
|
||||
}
|
||||
if (pendingInteraction) {
|
||||
if (pendingInteraction.kind === "request_confirmation") {
|
||||
if (isPlanConfirmation(pendingInteraction)) {
|
||||
return <>Waiting on board confirmation.</>;
|
||||
}
|
||||
return <>Waiting on confirmation.</>;
|
||||
}
|
||||
if (pendingInteraction.kind === "ask_user_questions") {
|
||||
return <>Waiting on user response.</>;
|
||||
}
|
||||
if (pendingInteraction.kind === "suggest_tasks") {
|
||||
return <>Waiting on task selection.</>;
|
||||
}
|
||||
}
|
||||
if (pendingApproval) {
|
||||
return <>Waiting on board approval.</>;
|
||||
}
|
||||
return <>Waiting on {ownerLabel}.</>;
|
||||
})();
|
||||
|
||||
const explicitWaitingBody = (() => {
|
||||
if (!showExplicitWaiting) return null;
|
||||
if (isWaitChain) {
|
||||
const leafChip = waitLeafIdentifier
|
||||
? <span className="font-mono text-xs">[{waitLeafIdentifier}]</span>
|
||||
: null;
|
||||
return leafChip
|
||||
? <>Work on this issue is paused while {ownerLabel} responds in {leafChip}. Nothing here will resume until that is resolved.</>
|
||||
: <>Work on this issue is paused while {ownerLabel} responds downstream. Nothing here will resume until that is resolved.</>;
|
||||
}
|
||||
if (pendingInteraction) {
|
||||
if (pendingInteraction.kind === "request_confirmation") {
|
||||
if (isPlanConfirmation(pendingInteraction)) {
|
||||
return <>Paperclip drafted a plan and asked the board to accept or reject it. Nothing will run until that's resolved.</>;
|
||||
}
|
||||
return <>Paperclip is waiting on {ownerLabel} to accept or reject.</>;
|
||||
}
|
||||
if (pendingInteraction.kind === "ask_user_questions") {
|
||||
const payload = pendingInteraction.payload as AskUserQuestionsPayload;
|
||||
const count = payload?.questions?.length ?? 0;
|
||||
const noun = count === 1 ? "question" : "questions";
|
||||
if (count > 0) {
|
||||
return <>Paperclip asked {ownerLabel} {count} {noun} before continuing.</>;
|
||||
}
|
||||
return <>Paperclip asked {ownerLabel} for a response before continuing.</>;
|
||||
}
|
||||
if (pendingInteraction.kind === "suggest_tasks") {
|
||||
const count = pendingInteraction.payload?.tasks?.length ?? 0;
|
||||
if (count > 0) {
|
||||
return <>Paperclip suggested {count} {count === 1 ? "task" : "tasks"}; pick which to create.</>;
|
||||
}
|
||||
return <>Paperclip suggested tasks for {ownerLabel} to pick which to create.</>;
|
||||
}
|
||||
}
|
||||
if (pendingApproval) {
|
||||
return <>A board approval is open against this issue.</>;
|
||||
}
|
||||
return <>This issue is on hold pending a human decision.</>;
|
||||
})();
|
||||
|
||||
const explicitWaitingResumeLabel = (() => {
|
||||
if (pendingInteraction) {
|
||||
const policy = pendingInteraction.continuationPolicy ?? "none";
|
||||
if (policy === "wake_assignee" || policy === "wake_assignee_on_accept") {
|
||||
if (pendingInteraction.kind === "request_confirmation") {
|
||||
return `Resumes when ${ownerLabel} accepts. Rejection returns to the assignee with reason.`;
|
||||
}
|
||||
if (pendingInteraction.kind === "ask_user_questions") {
|
||||
return `Resumes when ${ownerLabel} answers.`;
|
||||
}
|
||||
if (pendingInteraction.kind === "suggest_tasks") {
|
||||
return `Resumes when ${ownerLabel} picks tasks to create.`;
|
||||
}
|
||||
}
|
||||
return "Does not auto-resume — assignee must restart.";
|
||||
}
|
||||
if (pendingApproval) {
|
||||
return "Resumes on approval decision.";
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const explicitWaitingDetails = (() => {
|
||||
if (!showExplicitWaiting) return null;
|
||||
|
||||
const labelClass = "shrink-0 text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80";
|
||||
return (
|
||||
<dl className="space-y-0.5 pt-0.5 text-xs text-sky-900 dark:text-sky-100">
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<dt className={labelClass}>Owner</dt>
|
||||
<dd className="min-w-0 break-words">{ownerLabel}</dd>
|
||||
</div>
|
||||
{explicitWaitingResumeLabel ? (
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<dt className={labelClass}>Resume</dt>
|
||||
<dd className="min-w-0 break-words">{explicitWaitingResumeLabel}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
);
|
||||
})();
|
||||
|
||||
const explicitWaitingJumpAnchor = (() => {
|
||||
if (!showExplicitWaiting) return null;
|
||||
let anchorId: string | null = null;
|
||||
let label: string | null = null;
|
||||
if (pendingInteraction) {
|
||||
anchorId = `interaction-${pendingInteraction.id}`;
|
||||
if (pendingInteraction.kind === "request_confirmation") label = "Jump to confirmation";
|
||||
else if (pendingInteraction.kind === "ask_user_questions") label = "Jump to questions";
|
||||
else if (pendingInteraction.kind === "suggest_tasks") label = "Jump to suggestions";
|
||||
} else if (pendingApproval) {
|
||||
anchorId = `approval-${pendingApproval.id}`;
|
||||
label = "Jump to approval";
|
||||
}
|
||||
if (!anchorId || !label) return null;
|
||||
return <ExplicitWaitingJumpAnchor anchorId={anchorId} label={label} />;
|
||||
})();
|
||||
|
||||
const ownerPill = (() => {
|
||||
if (!isRecoveryNeeded) return null;
|
||||
const owner = blockerAttention?.nextActionOwner ?? null;
|
||||
@@ -468,8 +184,6 @@ export function IssueBlockedNotice({
|
||||
);
|
||||
})();
|
||||
|
||||
void issueId; // currently unused; reserved for future deep-anchor wiring
|
||||
|
||||
return (
|
||||
<div
|
||||
data-blocker-attention-state={blockerAttention?.state}
|
||||
@@ -477,19 +191,10 @@ export function IssueBlockedNotice({
|
||||
className={containerClass}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{showExplicitWaiting ? (
|
||||
<Hourglass className={iconClass} aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className={iconClass} />
|
||||
)}
|
||||
<AlertTriangle className={iconClass} />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
{showExplicitWaiting ? (
|
||||
<>
|
||||
<strong>{explicitWaitingHeadline}</strong>
|
||||
{explicitWaitingBody ? <> {explicitWaitingBody}</> : null}
|
||||
</>
|
||||
) : isRecoveryNeeded
|
||||
{isRecoveryNeeded
|
||||
? isLeafSurface
|
||||
? recoveryLeafCopy(blockerAttention!.reason as RecoveryReason)
|
||||
: recoveryParentCopy(
|
||||
@@ -504,35 +209,23 @@ export function IssueBlockedNotice({
|
||||
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
|
||||
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
|
||||
</p>
|
||||
{showExplicitWaiting ? (
|
||||
<>
|
||||
{explicitWaitingDetails}
|
||||
{isWaitChain && waitLeafBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Waiting at</span>
|
||||
{waitLeafBlockers.map((blocker) => renderBlockerChip(blocker, "sky"))}
|
||||
</div>
|
||||
) : null}
|
||||
{explicitWaitingJumpAnchor}
|
||||
</>
|
||||
) : null}
|
||||
{!showExplicitWaiting && !isRecoveryNeeded && blockers.length > 0 ? (
|
||||
{!isRecoveryNeeded && blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
</div>
|
||||
) : null}
|
||||
{!showExplicitWaiting && isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
|
||||
{isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Liveness break at</span>
|
||||
{recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
|
||||
</div>
|
||||
) : null}
|
||||
{!showExplicitWaiting && !isRecoveryNeeded && showStalledRow ? (
|
||||
{!isRecoveryNeeded && showStalledRow ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Stalled in review</span>
|
||||
{stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
</div>
|
||||
) : !showExplicitWaiting && !isRecoveryNeeded && terminalBlockers.length > 0 ? (
|
||||
) : !isRecoveryNeeded && terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Ultimately waiting on</span>
|
||||
{terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
@@ -544,49 +237,3 @@ export function IssueBlockedNotice({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplicitWaitingJumpAnchor({ anchorId, label }: { anchorId: string; label: string }) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [direction, setDirection] = useState<"up" | "down">("down");
|
||||
|
||||
useEffect(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
const update = () => {
|
||||
const target = document.getElementById(anchorId);
|
||||
if (!target) return;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const targetMid = targetRect.top + targetRect.height / 2;
|
||||
const buttonMid = buttonRect.top + buttonRect.height / 2;
|
||||
setDirection(targetMid < buttonMid ? "up" : "down");
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true, capture: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, { capture: true } as EventListenerOptions);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [anchorId]);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
const target = document.getElementById(anchorId);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const Arrow = direction === "up" ? ArrowUp : ArrowDown;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-sky-700 hover:underline dark:text-sky-300"
|
||||
>
|
||||
{label}
|
||||
<Arrow className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
} from "@tanstack/react-virtual";
|
||||
import type {
|
||||
Agent,
|
||||
Approval,
|
||||
FeedbackDataSharingPreference,
|
||||
FeedbackVote,
|
||||
FeedbackVoteValue,
|
||||
@@ -256,11 +255,8 @@ interface IssueChatComposerProps {
|
||||
}
|
||||
|
||||
interface IssueChatThreadProps {
|
||||
issueId?: string | null;
|
||||
issueIdentifier?: string | null;
|
||||
comments: IssueChatComment[];
|
||||
interactions?: IssueThreadInteraction[];
|
||||
approvals?: Approval[];
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl?: string | null;
|
||||
@@ -2961,11 +2957,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
});
|
||||
|
||||
export function IssueChatThread({
|
||||
issueId = null,
|
||||
issueIdentifier = null,
|
||||
comments,
|
||||
interactions = [],
|
||||
approvals = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
@@ -3535,16 +3528,9 @@ export function IssueChatThread({
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<IssueBlockedNotice
|
||||
issueId={issueId}
|
||||
issueIdentifier={issueIdentifier}
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
interactions={interactions}
|
||||
approvals={approvals}
|
||||
agentMap={agentMap}
|
||||
userLabelMap={userLabelMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
</div>
|
||||
|
||||
@@ -227,40 +227,6 @@ describe("IssueRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the sky waiting pill with the resolved owner label when blockerAttention.reason is explicit_waiting", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue({
|
||||
blockerAttention: {
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: null,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={issue} />);
|
||||
});
|
||||
|
||||
const pills = Array.from(container.querySelectorAll<HTMLSpanElement>("span"))
|
||||
.filter(
|
||||
(element) => element.textContent === "Waiting · board"
|
||||
&& element.className.includes("border-sky-200/80"),
|
||||
);
|
||||
expect(pills.length).toBeGreaterThan(0);
|
||||
expect(pills[0]?.getAttribute("title")).toBe("Waiting · board");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders without error when titleSuffix is omitted", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
@@ -35,37 +34,6 @@ interface IssueRowProps {
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
className?: string;
|
||||
agentNameMap?: ReadonlyMap<string, string> | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
currentUserId?: string | null;
|
||||
}
|
||||
|
||||
function truncateOwnerLabel(label: string, max = 18): string {
|
||||
if (label.length <= max) return label;
|
||||
return `${label.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
function resolveWaitingOwnerLabel(
|
||||
issue: Issue,
|
||||
agentNameMap: IssueRowProps["agentNameMap"],
|
||||
userLabelMap: IssueRowProps["userLabelMap"],
|
||||
currentUserId: string | null | undefined,
|
||||
): string | null {
|
||||
const owner = issue.blockerAttention?.nextActionOwner ?? null;
|
||||
if (!owner) return null;
|
||||
if (owner.type === "user") {
|
||||
if (!owner.userId) return "board";
|
||||
const resolved = formatAssigneeUserLabel(owner.userId, currentUserId ?? null, userLabelMap ?? undefined);
|
||||
return resolved ? truncateOwnerLabel(resolved) : null;
|
||||
}
|
||||
if (owner.type === "agent") {
|
||||
if (owner.agentId && agentNameMap) {
|
||||
const name = agentNameMap.get(owner.agentId);
|
||||
if (name) return truncateOwnerLabel(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function IssueRow({
|
||||
@@ -89,9 +57,6 @@ export function IssueRow({
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
className,
|
||||
agentNameMap,
|
||||
userLabelMap,
|
||||
currentUserId,
|
||||
}: IssueRowProps) {
|
||||
const issuePathId = issue.identifier ?? issue.id;
|
||||
const identifier = issue.identifier ?? issue.id.slice(0, 8);
|
||||
@@ -107,29 +72,6 @@ export function IssueRow({
|
||||
const recoveryLeafLabel = recoveryLeafIdentifier && recoveryLeafIdentifier !== identifier
|
||||
? `liveness break at ${recoveryLeafIdentifier}`
|
||||
: null;
|
||||
const isExplicitWaiting =
|
||||
issue.blockerAttention?.state === "covered"
|
||||
&& issue.blockerAttention?.reason === "explicit_waiting";
|
||||
const waitingOwnerLabel = isExplicitWaiting
|
||||
? resolveWaitingOwnerLabel(issue, agentNameMap, userLabelMap, currentUserId)
|
||||
: null;
|
||||
const waitingPillTitle = waitingOwnerLabel ? `Waiting · ${waitingOwnerLabel}` : "Waiting";
|
||||
const desktopWaitingPill = isExplicitWaiting ? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded border border-sky-200/80 bg-sky-50 px-1.5 py-0.5 text-[11px] font-medium text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200"
|
||||
title={waitingPillTitle}
|
||||
>
|
||||
{waitingPillTitle}
|
||||
</span>
|
||||
) : null;
|
||||
const mobileWaitingPill = isExplicitWaiting ? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded border border-sky-200/80 bg-sky-50 px-1.5 py-0.5 text-[11px] font-medium text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200"
|
||||
title={waitingPillTitle}
|
||||
>
|
||||
Waiting
|
||||
</span>
|
||||
) : null;
|
||||
const productivityReviewIndicator = productivityReview ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -213,20 +155,14 @@ export function IssueRow({
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{(desktopTrailing || trailingMeta || desktopWaitingPill) ? (
|
||||
{(desktopTrailing || trailingMeta) ? (
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
|
||||
{desktopWaitingPill}
|
||||
{desktopTrailing}
|
||||
{trailingMeta ? (
|
||||
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{mobileWaitingPill ? (
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-1.5 sm:hidden">
|
||||
{mobileWaitingPill}
|
||||
</span>
|
||||
) : null}
|
||||
{showUnreadSlot ? (
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
||||
{showUnreadDot ? (
|
||||
|
||||
@@ -411,7 +411,6 @@ describe("IssueRunLedger", () => {
|
||||
expect(container.textContent).toContain("Stale-run watchdog alert");
|
||||
expect(container.textContent).toContain("PAP-404");
|
||||
expect(container.textContent).toContain("Stale run");
|
||||
expect(container.textContent).toContain("assigned recovery owner can cancel this bound run");
|
||||
const watchdogBanner = Array.from(container.querySelectorAll("p"))
|
||||
.find((node) => node.textContent?.includes("Stale-run watchdog alert"))
|
||||
?.closest("div");
|
||||
@@ -430,20 +429,6 @@ describe("IssueRunLedger", () => {
|
||||
decision: "continue",
|
||||
evaluationIssueId: "issue-eval-1",
|
||||
});
|
||||
|
||||
const cancelButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Cancel run"),
|
||||
);
|
||||
expect(cancelButton).not.toBeUndefined();
|
||||
act(() => {
|
||||
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onWatchdogDecision).toHaveBeenCalledWith({
|
||||
runId: "run-live-1",
|
||||
decision: "cancel_run",
|
||||
evaluationIssueId: "issue-eval-1",
|
||||
reason: "Cancelled from stale-run recovery banner",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides watchdog decision actions for known non-owner viewers", () => {
|
||||
@@ -460,7 +445,6 @@ describe("IssueRunLedger", () => {
|
||||
expect(container.textContent).not.toContain("Continue monitoring");
|
||||
expect(container.textContent).not.toContain("Snooze 1h");
|
||||
expect(container.textContent).not.toContain("Mark false positive");
|
||||
expect(container.textContent).not.toContain("Cancel run");
|
||||
expect(container.querySelectorAll("button")).toHaveLength(0);
|
||||
expect(onWatchdogDecision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -575,9 +575,6 @@ export function IssueRunLedgerContent({
|
||||
{" "}for recovery context.
|
||||
</>
|
||||
) : null}
|
||||
{latestSilentRun.outputSilence.level === "critical" ? (
|
||||
<> The assigned recovery owner can cancel this bound run if the evidence shows it is stuck.</>
|
||||
) : null}
|
||||
</p>
|
||||
{onWatchdogDecision && canRecordWatchdogDecisions ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
@@ -623,22 +620,6 @@ export function IssueRunLedgerContent({
|
||||
>
|
||||
Mark false positive
|
||||
</button>
|
||||
{latestSilentRun.outputSilence.level === "critical" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500/40 bg-red-500/10 px-2 py-1 text-[11px] text-red-900 hover:bg-red-500/15 dark:text-red-200"
|
||||
onClick={() =>
|
||||
onWatchdogDecision({
|
||||
runId: latestSilentRun.runId,
|
||||
decision: "cancel_run",
|
||||
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
reason: "Cancelled from stale-run recovery banner",
|
||||
})}
|
||||
disabled={pendingWatchdogDecision != null}
|
||||
>
|
||||
Cancel run
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{watchdogDecisionError ? (
|
||||
|
||||
@@ -665,14 +665,6 @@ export function IssuesList({
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const agentNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const agent of agents ?? []) {
|
||||
map.set(agent.id, agent.name);
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const companyUserLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
@@ -1481,9 +1473,6 @@ export function IssuesList({
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
agentNameMap={agentNameById}
|
||||
userLabelMap={companyUserLabelMap}
|
||||
currentUserId={currentUserId}
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
|
||||
checklistDependencyChips={checklistDependencyChips}
|
||||
|
||||
@@ -129,56 +129,6 @@ describe("StatusIcon", () => {
|
||||
expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · automatic continuation exhausted"');
|
||||
});
|
||||
|
||||
it("renders explicit_waiting issues with sky ring and waiting-on-board copy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="in_progress"
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: null,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="explicit_waiting"');
|
||||
expect(html).toContain('aria-label="Waiting on board"');
|
||||
expect(html).toContain("border-sky-600");
|
||||
expect(html).not.toContain("border-cyan-600");
|
||||
expect(html).not.toContain("border-red-600");
|
||||
expect(html).toContain("-bottom-0.5");
|
||||
});
|
||||
|
||||
it("prefixes the explicit waiting tooltip with Blocked when the issue is also blocked", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-3000",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="Blocked · waiting on user"');
|
||||
expect(html).toContain("border-sky-600");
|
||||
});
|
||||
|
||||
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
|
||||
@@ -20,25 +20,9 @@ interface StatusIconProps {
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
function explicitWaitingTooltipBase(blockerAttention: IssueBlockerAttention | null | undefined): string {
|
||||
const owner = blockerAttention?.nextActionOwner ?? null;
|
||||
if (!owner) return "Waiting on a decision";
|
||||
if (owner.type === "user") {
|
||||
return owner.userId ? "Waiting on user" : "Waiting on board";
|
||||
}
|
||||
if (owner.type === "agent") {
|
||||
return "Waiting on agent";
|
||||
}
|
||||
return "Waiting on a decision";
|
||||
}
|
||||
|
||||
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
|
||||
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
|
||||
|
||||
if (blockerAttention.reason === "explicit_waiting") {
|
||||
return explicitWaitingTooltipBase(blockerAttention);
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "active_child") {
|
||||
const count = blockerAttention.coveredBlockerCount;
|
||||
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
|
||||
@@ -95,42 +79,27 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
||||
|
||||
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isExplicitWaiting =
|
||||
blockerAttention?.state === "covered"
|
||||
&& blockerAttention?.reason === "explicit_waiting";
|
||||
const isCoveredBlocked = status === "blocked"
|
||||
&& blockerAttention?.state === "covered"
|
||||
&& !isExplicitWaiting;
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
||||
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed";
|
||||
const colorClass = isExplicitWaiting
|
||||
? "text-sky-600 border-sky-600 dark:text-sky-400 dark:border-sky-400"
|
||||
: isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: isStalledBlocked
|
||||
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
|
||||
: isRecoveryNeeded
|
||||
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const colorClass = isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: isStalledBlocked
|
||||
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
|
||||
: isRecoveryNeeded
|
||||
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const isDone = status === "done";
|
||||
const explicitWaitingAriaLabel = isExplicitWaiting
|
||||
? (status === "blocked"
|
||||
? `Blocked · ${explicitWaitingTooltipBase(blockerAttention).toLowerCase()}`
|
||||
: explicitWaitingTooltipBase(blockerAttention))
|
||||
: null;
|
||||
const recoveryAriaLabel = isRecoveryNeeded ? blockedAttentionLabel(blockerAttention) : null;
|
||||
const ariaLabel = explicitWaitingAriaLabel
|
||||
?? recoveryAriaLabel
|
||||
const ariaLabel = recoveryAriaLabel
|
||||
?? (status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status));
|
||||
const blockerAttentionState = isExplicitWaiting
|
||||
? "explicit_waiting"
|
||||
: isCoveredBlocked
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: isRecoveryNeeded
|
||||
? "recovery_needed"
|
||||
: undefined;
|
||||
const blockerAttentionState = isCoveredBlocked
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: isRecoveryNeeded
|
||||
? "recovery_needed"
|
||||
: undefined;
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
@@ -147,7 +116,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
{isDone && (
|
||||
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
|
||||
)}
|
||||
{(isCoveredBlocked || isExplicitWaiting) && (
|
||||
{isCoveredBlocked && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
||||
)}
|
||||
{isStalledBlocked && (
|
||||
|
||||
@@ -2156,9 +2156,6 @@ export function Inbox() {
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
selected={selected}
|
||||
agentNameMap={agentById}
|
||||
userLabelMap={companyUserLabelMap}
|
||||
currentUserId={currentUserId}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
|
||||
@@ -27,7 +27,6 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
upsertDocument: vi.fn(),
|
||||
listApprovals: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActivityApi = vi.hoisted(() => ({
|
||||
@@ -783,7 +782,6 @@ describe("IssueDetail", () => {
|
||||
mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() });
|
||||
mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null });
|
||||
mockIssuesApi.listTreeHolds.mockResolvedValue([]);
|
||||
mockIssuesApi.listApprovals.mockResolvedValue([]);
|
||||
mockActivityApi.forIssue.mockResolvedValue([]);
|
||||
mockActivityApi.runsForIssue.mockResolvedValue([]);
|
||||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([]);
|
||||
@@ -1175,9 +1173,8 @@ describe("IssueDetail", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Paused by board.");
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paused by board.");
|
||||
expect(container.textContent).toContain("in_review");
|
||||
|
||||
@@ -570,7 +570,6 @@ function InboxMobileToolbar({
|
||||
|
||||
type IssueDetailChatTabProps = {
|
||||
issueId: string;
|
||||
issueIdentifier: string | null;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
issueStatus: Issue["status"];
|
||||
@@ -628,7 +627,6 @@ type IssueDetailChatTabProps = {
|
||||
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
issueId,
|
||||
issueIdentifier,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
@@ -704,14 +702,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
refetchInterval: hasLiveRuns ? 5000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||
});
|
||||
const { data: linkedApprovals } = useQuery({
|
||||
queryKey: queryKeys.issues.approvals(issueId),
|
||||
queryFn: () => issuesApi.listApprovals(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.listApprovals>>>(issueId),
|
||||
});
|
||||
const resolvedActivity = activity ?? [];
|
||||
const resolvedLinkedRuns = linkedRuns ?? [];
|
||||
const resolvedLinkedApprovals = linkedApprovals ?? [];
|
||||
|
||||
const runningIssueRun = useMemo(
|
||||
() => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns),
|
||||
@@ -829,11 +821,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
) : null}
|
||||
<IssueChatThread
|
||||
composerRef={composerRef}
|
||||
issueId={issueId}
|
||||
issueIdentifier={issueIdentifier}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
approvals={resolvedLinkedApprovals}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
@@ -3630,7 +3619,6 @@ export function IssueDetail() {
|
||||
{detailTab === "chat" ? (
|
||||
<IssueDetailChatTab
|
||||
issueId={issue.id}
|
||||
issueIdentifier={issue.identifier ?? null}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId ?? null}
|
||||
issueStatus={issue.status}
|
||||
|
||||
@@ -248,34 +248,6 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
|
||||
expectedVisual: "yellow ring + alert overlay",
|
||||
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
|
||||
},
|
||||
{
|
||||
label: "Explicit waiting (board confirmation)",
|
||||
status: "in_review",
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
expectedVisual: "sky ring + small dot",
|
||||
expectedCopy: "Waiting on board",
|
||||
},
|
||||
{
|
||||
label: "Explicit waiting (specific user)",
|
||||
status: "in_review",
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2710",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
expectedVisual: "sky ring + small dot",
|
||||
expectedCopy: "Waiting on user",
|
||||
},
|
||||
];
|
||||
|
||||
const coveredBlockedIssue = createIssue({
|
||||
@@ -310,22 +282,15 @@ type BlockedNoticeStateLabel =
|
||||
| "Stalled (single leaf)"
|
||||
| "Stalled (multiple leaves)"
|
||||
| "Recovery needed (parent perspective)"
|
||||
| "Recovery needed (leaf perspective)"
|
||||
| "Explicit wait (plan confirmation)"
|
||||
| "Explicit wait (user response)"
|
||||
| "Explicit wait (board approval)"
|
||||
| "Explicit wait (chain — leaf elsewhere)";
|
||||
| "Recovery needed (leaf perspective)";
|
||||
|
||||
type BlockedNoticeFixture = {
|
||||
label: BlockedNoticeStateLabel;
|
||||
caption: string;
|
||||
issueStatus: string;
|
||||
issueIdentifier?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention: IssueBlockerAttention;
|
||||
ownerAgentName?: string;
|
||||
interactions?: import("@paperclipai/shared").IssueThreadInteraction[];
|
||||
approvals?: import("@paperclipai/shared").Approval[];
|
||||
};
|
||||
|
||||
const stalledLeafSingle = summaryBlocker({
|
||||
@@ -464,134 +429,6 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
}),
|
||||
ownerAgentName: "CodexSparkCoder",
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (plan confirmation)",
|
||||
caption: "A pending board confirmation against the latest plan revision — sky banner, owner/resume rows, jump anchor.",
|
||||
issueStatus: "in_review",
|
||||
issueIdentifier: "PAP-2708",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
interactions: [
|
||||
{
|
||||
id: "interaction-plan",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-pap-2708",
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-28T22:00:00.000Z",
|
||||
updatedAt: "2026-04-28T22:00:00.000Z",
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByUserId: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve plan",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "rev-1",
|
||||
revisionNumber: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (user response)",
|
||||
caption: "Pending ask_user_questions waiting on a specific user — sky banner with owner/resume rows and a Jump to questions anchor.",
|
||||
issueStatus: "in_review",
|
||||
issueIdentifier: "PAP-2710",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2710",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
interactions: [
|
||||
{
|
||||
id: "interaction-questions",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-pap-2710",
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-28T22:00:00.000Z",
|
||||
updatedAt: "2026-04-28T22:00:00.000Z",
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByUserId: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [
|
||||
{ id: "q1", prompt: "Question 1?", selectionMode: "single", options: [] },
|
||||
{ id: "q2", prompt: "Question 2?", selectionMode: "single", options: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (board approval)",
|
||||
caption: "Pending board approval, no thread interaction — sky banner falls back to board-approval headline and Jump to approval anchor.",
|
||||
issueStatus: "in_review",
|
||||
issueIdentifier: "PAP-2711",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2711",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
approvals: [
|
||||
{
|
||||
id: "ca6ba09d-b558-4a53-a552-e7ef87e54a1b",
|
||||
companyId: "co-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-coder",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (chain — leaf elsewhere)",
|
||||
caption: "Parent issue whose downstream leaf has the pending wait — sky banner with the 'Waiting at' chip strip.",
|
||||
issueStatus: "blocked",
|
||||
issueIdentifier: "PAP-2712",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-wait-chain-leaf",
|
||||
identifier: "PAP-2708",
|
||||
title: "Plan confirmation pending downstream",
|
||||
status: "in_review",
|
||||
}),
|
||||
],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function BlockedNoticeSurface({
|
||||
@@ -616,13 +453,10 @@ function BlockedNoticeSurface({
|
||||
</div>
|
||||
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
|
||||
<IssueBlockedNotice
|
||||
issueIdentifier={fixture.issueIdentifier}
|
||||
issueStatus={fixture.issueStatus}
|
||||
blockers={fixture.blockers}
|
||||
blockerAttention={fixture.blockerAttention}
|
||||
ownerAgentName={fixture.ownerAgentName}
|
||||
interactions={fixture.interactions}
|
||||
approvals={fixture.approvals}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user