PAP-2614: Surface productivity review state in issue UI

Adds a productivityReview lookup to single-issue and list responses, plus
a ProductivityReviewBadge component that surfaces the open productivity
review on the source issue's header (with trigger/no-comment-streak
detail in the tooltip), and a compact eye glyph next to the StatusIcon
on IssueRow so operators can spot yellow tasks in any list. The review
issue itself shows a static "Productivity review" pill in the header
when its originKind matches.

Also extends the shared IssueOriginKind union with the recovery-side
kinds (harness_liveness_escalation, issue_productivity_review,
stranded_issue_recovery) so UI checks against issue.originKind type-check.

Wires the new helper through GET /issues/:id, GET /companies/:id/issues
(via issueService.list), and GET /issues/:id/heartbeat-context. Adds a
status-language Storybook story matrix for UX review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 16:48:58 -05:00
1 parent 8a34401d80
commit dd3aa3569f
4 files changed
+228 -4

No files matched your search

+19 -3
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { Eye, X } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@@ -9,6 +9,7 @@ import {
} from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
type UnreadState = "hidden" | "visible" | "fading";
@@ -63,6 +64,19 @@ export function IssueRow({
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const productivityReview = issue.productivityReview ?? null;
const productivityReviewIndicator = productivityReview ? (
<span
className={cn(
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300",
selected ? "border-muted-foreground text-muted-foreground" : null,
)}
title={`Productivity review: ${productivityReviewTriggerLabel(productivityReview.trigger)}`}
aria-label="Productivity review open"
>
<Eye className="h-2.5 w-2.5" aria-hidden />
</span>
) : null;
const hasChecklistStep = checklistStepNumber !== null;
const checklistStep = hasChecklistStep ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
@@ -87,8 +101,9 @@ export function IssueRow({
className,
)}
>
<span className="shrink-0 pt-px sm:hidden">
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
{productivityReviewIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
@@ -105,8 +120,9 @@ export function IssueRow({
) : null}
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
{productivityReviewIndicator}
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
@@ -0,0 +1,77 @@
import { Link } from "react-router-dom";
import { Eye } from "lucide-react";
import type { IssueProductivityReview } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const TRIGGER_LABELS: Record<string, string> = {
no_comment_streak: "No-comment streak",
long_active_duration: "Long active duration",
high_churn: "High churn",
};
const REVIEW_STATUS_LABELS: Record<string, string> = {
todo: "Open",
in_progress: "In progress",
in_review: "In review",
blocked: "Blocked",
backlog: "Open",
};
export function productivityReviewTriggerLabel(
trigger: IssueProductivityReview["trigger"],
): string {
if (!trigger) return "Productivity review";
return TRIGGER_LABELS[trigger] ?? "Productivity review";
}
export function ProductivityReviewBadge({
review,
className,
hideLabel = false,
}: {
review: IssueProductivityReview;
className?: string;
hideLabel?: boolean;
}) {
const label = productivityReviewTriggerLabel(review.trigger);
const reviewIdentifier = review.reviewIdentifier ?? review.reviewIssueId.slice(0, 8);
const reviewPath = createIssueDetailPath(review.reviewIdentifier ?? review.reviewIssueId);
const statusLabel = REVIEW_STATUS_LABELS[review.status] ?? review.status.replace(/_/g, " ");
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
to={reviewPath}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0 hover:bg-amber-500/20 transition-colors",
className,
)}
aria-label={`Productivity review ${reviewIdentifier} (${label})`}
>
<Eye className="h-3 w-3" aria-hidden />
{hideLabel ? null : <span>Productivity review</span>}
</Link>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 text-xs">
<div className="font-semibold">Productivity review open</div>
<div>
<span className="text-muted-foreground">Trigger:</span> {label}
</div>
{typeof review.noCommentStreak === "number" && review.noCommentStreak > 0 ? (
<div>
<span className="text-muted-foreground">No-comment streak:</span>{" "}
{review.noCommentStreak} runs
</div>
) : null}
<div>
<span className="text-muted-foreground">Review:</span> {reviewIdentifier} ({statusLabel})
</div>
</div>
</TooltipContent>
</Tooltip>
);
}
+16
View File
@@ -77,6 +77,7 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
@@ -108,6 +109,7 @@ import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
Hexagon,
ListTree,
@@ -2906,6 +2908,20 @@ export function IssueDetail() {
</Link>
)}
{issue.productivityReview ? (
<ProductivityReviewBadge review={issue.productivityReview} />
) : null}
{issue.originKind === "issue_productivity_review" ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
title="This task is a productivity review."
>
<Eye className="h-3 w-3" />
Productivity review
</span>
) : null}
{issue.projectId ? (
<Link
to={`/projects/${issue.projectId}`}
@@ -1,7 +1,11 @@
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, IssueRelationIssueSummary } from "@paperclipai/shared";
import type {
IssueBlockerAttention,
IssueProductivityReview,
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";
@@ -10,6 +14,7 @@ import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { IssueRow } from "@/components/IssueRow";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { ProductivityReviewBadge } from "@/components/ProductivityReviewBadge";
import { QuotaBar } from "@/components/QuotaBar";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
@@ -370,6 +375,112 @@ function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "
);
}
type ProductivityReviewFixture = {
label: string;
description: string;
review: IssueProductivityReview;
};
const productivityReviewFixtures: ProductivityReviewFixture[] = [
{
label: "No-comment streak",
description: "Source issue has had 12 completed runs without a run-created comment.",
review: {
reviewIssueId: "review-issue-1",
reviewIdentifier: "PAP-2702",
status: "todo",
priority: "high",
trigger: "no_comment_streak",
noCommentStreak: 12,
createdAt: new Date("2026-04-28T13:30:00.000Z"),
updatedAt: new Date("2026-04-28T13:55:00.000Z"),
},
},
{
label: "Long active duration",
description: "Source issue has been actively running for over 6 hours.",
review: {
reviewIssueId: "review-issue-2",
reviewIdentifier: "PAP-2703",
status: "in_progress",
priority: "medium",
trigger: "long_active_duration",
noCommentStreak: null,
createdAt: new Date("2026-04-28T08:30:00.000Z"),
updatedAt: new Date("2026-04-28T13:00:00.000Z"),
},
},
{
label: "High churn",
description: "Source issue is producing >10 runs/comments per hour.",
review: {
reviewIssueId: "review-issue-3",
reviewIdentifier: "PAP-2704",
status: "todo",
priority: "high",
trigger: "high_churn",
noCommentStreak: 4,
createdAt: new Date("2026-04-28T13:45:00.000Z"),
updatedAt: new Date("2026-04-28T13:55:00.000Z"),
},
},
];
const productivityReviewIssueRowFixtures = productivityReviewFixtures.map((fixture, index) =>
createIssue({
id: `issue-productivity-source-${index + 1}`,
identifier: `PAP-${2710 + index}`,
issueNumber: 2710 + index,
title: `Source issue under review · ${fixture.label}`,
status: index === 1 ? "in_progress" : "in_progress",
priority: fixture.review.priority,
productivityReview: fixture.review,
lastActivityAt: fixture.review.updatedAt,
updatedAt: fixture.review.updatedAt,
}),
);
function ProductivityReviewMatrix() {
return (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-3">
{productivityReviewFixtures.map((fixture) => (
<div
key={fixture.label}
className="flex flex-col gap-3 rounded-lg border border-border bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{fixture.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{fixture.description}</div>
</div>
<ProductivityReviewBadge review={fixture.review} />
</div>
<div className="rounded-md bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
Trigger {fixture.review.trigger ?? "unknown"} · review {fixture.review.reviewIdentifier}
</div>
</div>
))}
</div>
<div className="rounded-lg border border-border">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
IssueRow with productivity-review indicator
</div>
<div>
{productivityReviewIssueRowFixtures.map((issue) => (
<IssueRow key={issue.id} issue={issue} mobileMeta={<StatusBadge status={issue.status} />} />
))}
</div>
</div>
<p className="text-xs text-muted-foreground">
The amber pill links from the source issue header to the open productivity-review child. List rows
get a smaller eye glyph next to the status icon so operators can spot yellow tasks without the
clickable label. The review issue itself shows the same amber pill in static (non-link) form.
</p>
</div>
);
}
function StatusLanguage() {
const [priority, setPriority] = useState("high");
@@ -466,6 +577,10 @@ function StatusLanguage() {
</p>
</Section>
<Section eyebrow="Productivity review" title="Yellow accountability state on source issues">
<ProductivityReviewMatrix />
</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">