mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00: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>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue