mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Cancel stale queued heartbeats when issue graph changes (PAP-2314) (#4534)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
868d08903e
commit
82e257c7ba
21 changed files with 1991 additions and 238 deletions
102
ui/src/components/IssueBlockedNotice.tsx
Normal file
102
ui/src/components/IssueBlockedNotice.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueStatus,
|
||||
blockers,
|
||||
blockerAttention,
|
||||
}: {
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
}) {
|
||||
if (blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
.flatMap((blocker) => blocker.terminalBlockers ?? [])
|
||||
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
|
||||
|
||||
const isStalled = blockerAttention?.state === "stalled";
|
||||
const stalledLeafIdentifier =
|
||||
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
|
||||
const stalledLeafBlockers = (() => {
|
||||
const candidates: IssueRelationIssueSummary[] = [];
|
||||
for (const blocker of [...blockers, ...terminalBlockers]) {
|
||||
if (blocker.status !== "in_review") continue;
|
||||
if (candidates.some((existing) => existing.id === blocker.id)) continue;
|
||||
candidates.push(blocker);
|
||||
}
|
||||
if (stalledLeafIdentifier) {
|
||||
const preferred = candidates.find(
|
||||
(blocker) => (blocker.identifier ?? blocker.id) === stalledLeafIdentifier,
|
||||
);
|
||||
if (preferred) {
|
||||
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
})();
|
||||
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
|
||||
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
issuePathId={issuePathId}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
className="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"
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-blocker-attention-state={blockerAttention?.state}
|
||||
className="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"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
{blockers.length > 0
|
||||
? isStalled
|
||||
? stalledLeafBlockers.length > 1
|
||||
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
|
||||
: <>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>
|
||||
{blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{showStalledRow ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Stalled in review
|
||||
</span>
|
||||
{stalledLeafBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Ultimately waiting on
|
||||
</span>
|
||||
{terminalBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
FeedbackVote,
|
||||
FeedbackVoteValue,
|
||||
IssueAttachment,
|
||||
IssueBlockerAttention,
|
||||
IssueRelationIssueSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
|
|
@ -88,7 +89,6 @@ import {
|
|||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
describeToolInput,
|
||||
|
|
@ -104,7 +104,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||
|
|
@ -245,6 +245,7 @@ interface IssueChatThreadProps {
|
|||
liveRuns?: LiveRunForIssue[];
|
||||
activeRun?: ActiveRunForIssue | null;
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
issueStatus?: string;
|
||||
|
|
@ -344,66 +345,6 @@ class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, Issu
|
|||
}
|
||||
}
|
||||
|
||||
function IssueBlockedNotice({
|
||||
issueStatus,
|
||||
blockers,
|
||||
}: {
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
}) {
|
||||
if (blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
.flatMap((blocker) => blocker.terminalBlockers ?? [])
|
||||
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
|
||||
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
issuePathId={issuePathId}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
className="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"
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
{blockers.length > 0
|
||||
? <>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>
|
||||
{blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Ultimately waiting on
|
||||
</span>
|
||||
{terminalBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueAssigneePausedNotice({ agent }: { agent: Agent | null }) {
|
||||
if (!agent || agent.status !== "paused") return null;
|
||||
|
||||
|
|
@ -2511,6 +2452,7 @@ export function IssueChatThread({
|
|||
liveRuns = [],
|
||||
activeRun = null,
|
||||
blockedBy = [],
|
||||
blockerAttention = null,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
|
|
@ -2867,7 +2809,11 @@ export function IssueChatThread({
|
|||
)}
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
|
||||
<IssueBlockedNotice
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
/>
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -677,8 +677,7 @@ export function IssueDocumentsSection({
|
|||
};
|
||||
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
||||
const documentBodyPaddingClassName = "";
|
||||
const documentBodyShellClassName = "mt-3";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
const toggleFoldedDocument = (key: string) => {
|
||||
setFoldedDocumentKeys((current) =>
|
||||
|
|
@ -784,9 +783,7 @@ export function IssueDocumentsSection({
|
|||
PLAN
|
||||
</span>
|
||||
</div>
|
||||
<div className={documentBodyPaddingClassName}>
|
||||
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
</div>
|
||||
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1088,14 +1085,12 @@ export function IssueDocumentsSection({
|
|||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft || isHistoricalPreview ? "" : "hover:bg-accent/10"
|
||||
className={`${documentBodyShellClassName} ${
|
||||
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
{isHistoricalPreview ? (
|
||||
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
||||
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
|
||||
</div>
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
|
|
@ -1117,9 +1112,7 @@ export function IssueDocumentsSection({
|
|||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-background/40 p-3">
|
||||
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
|
||||
</div>
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
|
|
|
|||
|
|
@ -403,8 +403,10 @@ describe("IssueProperties", () => {
|
|||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
},
|
||||
}),
|
||||
childIssues: [],
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ describe("StatusIcon", () => {
|
|||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -38,8 +40,10 @@ describe("StatusIcon", () => {
|
|||
reason: "active_dependency",
|
||||
unresolvedBlockerCount: 2,
|
||||
coveredBlockerCount: 2,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: null,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -58,8 +62,10 @@ describe("StatusIcon", () => {
|
|||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -69,4 +75,28 @@ describe("StatusIcon", () => {
|
|||
expect(html).toContain("border-red-600");
|
||||
expect(html).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "stalled",
|
||||
reason: "stalled_review",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2279",
|
||||
sampleStalledBlockerIdentifier: "PAP-2279",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="stalled"');
|
||||
expect(html).toContain('aria-label="Blocked · review stalled on PAP-2279"');
|
||||
expect(html).toContain("border-amber-600");
|
||||
expect(html).not.toContain("border-cyan-600");
|
||||
expect(html).not.toContain("border-red-600");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
|||
return `Blocked · covered by ${count} active dependencies`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "stalled_review") {
|
||||
const count = blockerAttention.stalledBlockerCount;
|
||||
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
|
||||
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
|
||||
if (count === 1) return "Blocked · review stalled with no clear next step";
|
||||
return `Blocked · ${count} reviews stalled with no clear next step`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "attention_required") {
|
||||
const count = blockerAttention.unresolvedBlockerCount;
|
||||
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
|
|
@ -51,11 +59,19 @@ 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 isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
||||
const colorClass = isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
: isStalledBlocked
|
||||
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const isDone = status === "done";
|
||||
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status);
|
||||
const blockerAttentionState = isCoveredBlocked
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: undefined;
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
|
|
@ -65,7 +81,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
|||
onChange && !showLabel && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
data-blocker-attention-state={isCoveredBlocked ? "covered" : undefined}
|
||||
data-blocker-attention-state={blockerAttentionState}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
|
|
@ -75,6 +91,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
|||
{isCoveredBlocked && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
||||
)}
|
||||
{isStalledBlocked && (
|
||||
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -824,8 +824,10 @@ describe("IssueDetail", () => {
|
|||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -551,6 +551,7 @@ type IssueDetailChatTabProps = {
|
|||
issueStatus: Issue["status"];
|
||||
executionRunId: string | null;
|
||||
blockedBy: Issue["blockedBy"];
|
||||
blockerAttention: Issue["blockerAttention"] | null;
|
||||
comments: IssueDetailComment[];
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
interactions: IssueThreadInteraction[];
|
||||
|
|
@ -603,6 +604,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
issueStatus,
|
||||
executionRunId,
|
||||
blockedBy,
|
||||
blockerAttention,
|
||||
comments,
|
||||
locallyQueuedCommentRunIds,
|
||||
interactions,
|
||||
|
|
@ -797,6 +799,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={resolvedActiveRun}
|
||||
blockedBy={blockedBy ?? []}
|
||||
blockerAttention={blockerAttention}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus={issueStatus}
|
||||
|
|
@ -3392,6 +3395,7 @@ export function IssueDetail() {
|
|||
issueStatus={issue.status}
|
||||
executionRunId={issue.executionRunId ?? null}
|
||||
blockedBy={issue.blockedBy ?? []}
|
||||
blockerAttention={issue.blockerAttention ?? null}
|
||||
comments={threadComments}
|
||||
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
||||
interactions={interactions}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
|
||||
import { CopyText } from "@/components/CopyText";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
|
||||
import { IssueRow } from "@/components/IssueRow";
|
||||
import { MetricCard } from "@/components/MetricCard";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
|
|
@ -43,6 +44,21 @@ type CoveredBlockedCell = {
|
|||
expectedCopy: string;
|
||||
};
|
||||
|
||||
function attention(
|
||||
partial: Partial<IssueBlockerAttention> & Pick<IssueBlockerAttention, "state" | "reason">,
|
||||
): IssueBlockerAttention {
|
||||
return {
|
||||
state: partial.state,
|
||||
reason: partial.reason,
|
||||
unresolvedBlockerCount: partial.unresolvedBlockerCount ?? 0,
|
||||
coveredBlockerCount: partial.coveredBlockerCount ?? 0,
|
||||
stalledBlockerCount: partial.stalledBlockerCount ?? 0,
|
||||
attentionBlockerCount: partial.attentionBlockerCount ?? 0,
|
||||
sampleBlockerIdentifier: partial.sampleBlockerIdentifier ?? null,
|
||||
sampleStalledBlockerIdentifier: partial.sampleStalledBlockerIdentifier ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const coveredBlockedMatrix: CoveredBlockedCell[] = [
|
||||
{
|
||||
label: "Normal blocked",
|
||||
|
|
@ -54,98 +70,116 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
|
|||
{
|
||||
label: "Covered by 1 active child",
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2175",
|
||||
},
|
||||
}),
|
||||
expectedVisual: "cyan ring",
|
||||
expectedCopy: "Blocked · waiting on active sub-issue PAP-2175",
|
||||
},
|
||||
{
|
||||
label: "Covered by N active children",
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 3,
|
||||
coveredBlockerCount: 3,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: null,
|
||||
},
|
||||
}),
|
||||
expectedVisual: "cyan ring",
|
||||
expectedCopy: "Blocked · waiting on 3 active sub-issues",
|
||||
},
|
||||
{
|
||||
label: "Covered by active dependency",
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "active_dependency",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-1918",
|
||||
},
|
||||
}),
|
||||
expectedVisual: "cyan ring",
|
||||
expectedCopy: "Blocked · covered by active dependency PAP-1918",
|
||||
},
|
||||
{
|
||||
label: "Covered by N active dependencies",
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "active_dependency",
|
||||
unresolvedBlockerCount: 2,
|
||||
coveredBlockerCount: 2,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: null,
|
||||
},
|
||||
}),
|
||||
expectedVisual: "cyan ring",
|
||||
expectedCopy: "Blocked · covered by 2 active dependencies",
|
||||
},
|
||||
{
|
||||
label: "Stalled review (single leaf)",
|
||||
status: "blocked",
|
||||
blockerAttention: attention({
|
||||
state: "stalled",
|
||||
reason: "stalled_review",
|
||||
unresolvedBlockerCount: 1,
|
||||
stalledBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2279",
|
||||
sampleStalledBlockerIdentifier: "PAP-2279",
|
||||
}),
|
||||
expectedVisual: "amber ring with dot",
|
||||
expectedCopy: "Blocked · review stalled on PAP-2279",
|
||||
},
|
||||
{
|
||||
label: "Stalled review (multiple leaves)",
|
||||
status: "blocked",
|
||||
blockerAttention: attention({
|
||||
state: "stalled",
|
||||
reason: "stalled_review",
|
||||
unresolvedBlockerCount: 2,
|
||||
stalledBlockerCount: 2,
|
||||
sampleStalledBlockerIdentifier: "PAP-2279",
|
||||
}),
|
||||
expectedVisual: "amber ring with dot",
|
||||
expectedCopy: "Blocked · 2 reviews stalled with no clear next step",
|
||||
},
|
||||
{
|
||||
label: "Mixed: 1 covered, 1 needs attention",
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 2,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: null,
|
||||
},
|
||||
}),
|
||||
expectedVisual: "solid red ring",
|
||||
expectedCopy: "Blocked · 2 unresolved blockers need attention",
|
||||
},
|
||||
{
|
||||
label: "Needs attention (single blocker)",
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-1042",
|
||||
},
|
||||
}),
|
||||
expectedVisual: "solid red ring",
|
||||
expectedCopy: "Blocked · 1 unresolved blocker needs attention",
|
||||
},
|
||||
{
|
||||
label: "Non-blocked with prop ignored",
|
||||
status: "in_progress",
|
||||
blockerAttention: {
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2175",
|
||||
},
|
||||
}),
|
||||
expectedVisual: "yellow ring",
|
||||
expectedCopy: "In Progress",
|
||||
},
|
||||
|
|
@ -163,6 +197,157 @@ const coveredBlockedIssue = createIssue({
|
|||
updatedAt: new Date("2026-04-24T13:40:00.000Z"),
|
||||
});
|
||||
|
||||
function summaryBlocker(
|
||||
partial: Partial<IssueRelationIssueSummary> & Pick<IssueRelationIssueSummary, "id" | "title" | "status">,
|
||||
): IssueRelationIssueSummary {
|
||||
return {
|
||||
id: partial.id,
|
||||
identifier: partial.identifier ?? null,
|
||||
title: partial.title,
|
||||
status: partial.status,
|
||||
priority: partial.priority ?? "medium",
|
||||
assigneeAgentId: partial.assigneeAgentId ?? null,
|
||||
assigneeUserId: partial.assigneeUserId ?? null,
|
||||
terminalBlockers: partial.terminalBlockers,
|
||||
};
|
||||
}
|
||||
|
||||
type BlockedNoticeStateLabel =
|
||||
| "Default covered"
|
||||
| "Stalled (single leaf)"
|
||||
| "Stalled (multiple leaves)";
|
||||
|
||||
type BlockedNoticeFixture = {
|
||||
label: BlockedNoticeStateLabel;
|
||||
caption: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention: IssueBlockerAttention;
|
||||
};
|
||||
|
||||
const stalledLeafSingle = summaryBlocker({
|
||||
id: "issue-stalled-leaf-single",
|
||||
identifier: "PAP-2279",
|
||||
title: "Stage gate review for export pipeline",
|
||||
status: "in_review",
|
||||
});
|
||||
|
||||
const stalledLeafMultiPrimary = summaryBlocker({
|
||||
id: "issue-stalled-leaf-multi-1",
|
||||
identifier: "PAP-2284",
|
||||
title: "Approve schema migration",
|
||||
status: "in_review",
|
||||
});
|
||||
|
||||
const stalledLeafMultiSecondary = summaryBlocker({
|
||||
id: "issue-stalled-leaf-multi-2",
|
||||
identifier: "PAP-2291",
|
||||
title: "Sign off on rollout copy",
|
||||
status: "in_review",
|
||||
});
|
||||
|
||||
const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
{
|
||||
label: "Default covered",
|
||||
caption: "Active sub-issue covers the chain — informational only.",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-active-child",
|
||||
identifier: "PAP-2175",
|
||||
title: "Wire export pipeline preview",
|
||||
status: "in_progress",
|
||||
}),
|
||||
],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2175",
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Stalled (single leaf)",
|
||||
caption: "Chain stalled on one leaf review — copy names the leaf and shows the chip strip.",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-stalled-parent-single",
|
||||
identifier: "PAP-2278",
|
||||
title: "Ship rollout dashboard",
|
||||
status: "blocked",
|
||||
terminalBlockers: [stalledLeafSingle],
|
||||
}),
|
||||
],
|
||||
blockerAttention: attention({
|
||||
state: "stalled",
|
||||
reason: "stalled_review",
|
||||
unresolvedBlockerCount: 1,
|
||||
stalledBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2279",
|
||||
sampleStalledBlockerIdentifier: "PAP-2279",
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Stalled (multiple leaves)",
|
||||
caption: "Multiple stalled reviews — body uses plural agreement (\"reviews\"/\"them\") to match the chip strip.",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-stalled-parent-multi-a",
|
||||
identifier: "PAP-2283",
|
||||
title: "Coordinate billing change rollout",
|
||||
status: "blocked",
|
||||
terminalBlockers: [stalledLeafMultiPrimary],
|
||||
}),
|
||||
summaryBlocker({
|
||||
id: "issue-stalled-parent-multi-b",
|
||||
identifier: "PAP-2290",
|
||||
title: "Coordinate marketing handoff",
|
||||
status: "blocked",
|
||||
terminalBlockers: [stalledLeafMultiSecondary],
|
||||
}),
|
||||
],
|
||||
blockerAttention: attention({
|
||||
state: "stalled",
|
||||
reason: "stalled_review",
|
||||
unresolvedBlockerCount: 2,
|
||||
stalledBlockerCount: 2,
|
||||
sampleStalledBlockerIdentifier: "PAP-2284",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function BlockedNoticeSurface({
|
||||
mode,
|
||||
size,
|
||||
fixture,
|
||||
}: {
|
||||
mode: "light" | "dark";
|
||||
size: "desktop" | "mobile";
|
||||
fixture: BlockedNoticeFixture;
|
||||
}) {
|
||||
const isDark = mode === "dark";
|
||||
const isMobile = size === "mobile";
|
||||
return (
|
||||
<div className={isDark ? "dark" : undefined}>
|
||||
<div className="rounded-lg border border-border bg-background text-foreground">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{fixture.label}</span>
|
||||
<span className="font-mono">
|
||||
{size} · {mode}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={fixture.blockers}
|
||||
blockerAttention={fixture.blockerAttention}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "desktop" | "mobile" }) {
|
||||
const isDark = mode === "dark";
|
||||
const isMobile = size === "mobile";
|
||||
|
|
@ -248,8 +433,9 @@ function StatusLanguage() {
|
|||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Tooltip and aria-label copy begin with "Blocked · " for cells 2-7; cells 6 and 7 retain the solid red ring
|
||||
and mention blockers that need attention.
|
||||
Tooltip and aria-label copy begin with "Blocked · " for every cell after the first. Covered cells show a cyan
|
||||
ring with a small dot, stalled-review cells show an amber ring with a centered dot, and the needs-attention
|
||||
cells retain the solid red ring.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
|
|
@ -262,6 +448,24 @@ function StatusLanguage() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Covered blocked" title="IssueBlockedNotice in chat thread">
|
||||
<div className="space-y-5">
|
||||
{blockedNoticeFixtures.map((fixture) => (
|
||||
<div key={fixture.label} className="grid gap-4 xl:grid-cols-2">
|
||||
<BlockedNoticeSurface mode="light" size="desktop" fixture={fixture} />
|
||||
<BlockedNoticeSurface mode="dark" size="desktop" fixture={fixture} />
|
||||
<BlockedNoticeSurface mode="light" size="mobile" fixture={fixture} />
|
||||
<BlockedNoticeSurface mode="dark" size="mobile" fixture={fixture} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Stalled-state copy switches to "stalled in review without a clear next step" and adds a "Stalled in review"
|
||||
chip strip beneath the regular blocker chips. The trailing imperative pluralizes when multiple stalled
|
||||
leaves are surfaced ("reviews"/"them") to match the chip strip.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue