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 09:27:16 -05:00
1 parent e7d32f19cd
commit b779e43272
10 files changed
+458 -19

No files matched your search

+14 -1
View File
@@ -141,6 +141,12 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
export function clampIssueRequestDepth(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value)));
}
export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks",
@@ -167,7 +173,14 @@ export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [
export type IssueThreadInteractionContinuationPolicy =
(typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number];
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution", "stale_active_run_evaluation"] as const;
export const ISSUE_ORIGIN_KINDS = [
"manual",
"routine_execution",
"stale_active_run_evaluation",
"harness_liveness_escalation",
"issue_productivity_review",
"stranded_issue_recovery",
] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
+4
View File
@@ -18,6 +18,8 @@ export {
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
MAX_ISSUE_REQUEST_DEPTH,
clampIssueRequestDepth,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
@@ -331,6 +333,8 @@ export type {
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
+2
View File
@@ -136,6 +136,8 @@ export type {
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
+17
View File
@@ -139,6 +139,22 @@ export interface IssueBlockerAttention {
sampleStalledBlockerIdentifier: string | null;
}
export type IssueProductivityReviewTrigger =
| "no_comment_streak"
| "long_active_duration"
| "high_churn";
export interface IssueProductivityReview {
reviewIssueId: string;
reviewIdentifier: string | null;
status: IssueStatus;
priority: IssuePriority;
trigger: IssueProductivityReviewTrigger | null;
noCommentStreak: number | null;
createdAt: Date;
updatedAt: Date;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -264,6 +280,7 @@ export interface Issue {
blockedBy?: IssueRelationIssueSummary[];
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;
+42 -10
View File
@@ -603,23 +603,39 @@ export function issueRoutes(
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (issue.status !== "in_progress" || issue.assigneeAgentId === null) {
if (issue.assigneeAgentId === null) {
return true;
}
if (issue.assigneeAgentId !== actorAgentId) {
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
return true;
}
res.status(409).json({
error: "Issue is checked out by another agent",
details: {
issueId: issue.id,
assigneeAgentId: issue.assigneeAgentId,
actorAgentId,
},
});
if (issue.status === "in_progress") {
res.status(409).json({
error: "Issue is checked out by another agent",
details: {
issueId: issue.id,
assigneeAgentId: issue.assigneeAgentId,
actorAgentId,
},
});
} else {
res.status(403).json({
error: "Agent cannot mutate another agent's issue",
details: {
issueId: issue.id,
assigneeAgentId: issue.assigneeAgentId,
actorAgentId,
status: issue.status,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
}
return false;
}
if (issue.status !== "in_progress") {
return true;
}
const runId = requireAgentRunId(req, res);
if (!runId) return false;
const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
@@ -1035,6 +1051,7 @@ export function issueRoutes(
wakeComment,
relations,
blockerAttention,
productivityReview,
attachments,
continuationSummary,
currentExecutionWorkspace,
@@ -1046,6 +1063,7 @@ export function issueRoutes(
wakeCommentId ? svc.getComment(wakeCommentId) : null,
svc.getRelationSummaries(issue.id),
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
svc.listAttachments(issue.id),
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
currentExecutionWorkspacePromise,
@@ -1059,6 +1077,7 @@ export function issueRoutes(
description: issue.description,
status: issue.status,
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
@@ -1067,6 +1086,8 @@ export function issueRoutes(
blocks: relations.blocks,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
originKind: issue.originKind,
originId: issue.originId,
updatedAt: issue.updatedAt,
},
ancestors: ancestors.map((ancestor) => ({
@@ -1128,13 +1149,23 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, blockerAttention, referenceSummary] = await Promise.all([
const [
{ project, goal },
ancestors,
mentionedProjectIds,
documentPayload,
relations,
blockerAttention,
productivityReview,
referenceSummary,
] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
documentsSvc.getIssueDocumentPayload(issue),
svc.getRelationSummaries(issue.id),
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
@@ -1149,6 +1180,7 @@ export function issueRoutes(
goalId: goal?.id ?? issue.goalId,
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
relatedWork: referenceSummary,
+151 -4
View File
@@ -27,8 +27,13 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
import type {
IssueBlockerAttention,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@@ -666,6 +671,17 @@ const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execut
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"];
const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [
"issue.productivity_review_created",
"issue.productivity_review_updated",
];
const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] = [
"no_comment_streak",
"long_active_duration",
"high_churn",
];
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
const BLOCKER_ATTENTION_MAX_NODES = 2000;
@@ -876,6 +892,114 @@ async function terminalExplicitBlockersByRoot(
return terminalByRoot;
}
function readProductivityReviewTrigger(value: unknown): IssueProductivityReviewTrigger | null {
if (typeof value !== "string") return null;
return PRODUCTIVITY_REVIEW_TRIGGERS.includes(value as IssueProductivityReviewTrigger)
? (value as IssueProductivityReviewTrigger)
: null;
}
function readProductivityReviewStreak(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
return Math.floor(value);
}
async function listIssueProductivityReviewMap(
dbOrTx: any,
companyId: string,
sourceIssueIds: string[],
): Promise<Map<string, IssueProductivityReview>> {
const map = new Map<string, IssueProductivityReview>();
if (sourceIssueIds.length === 0) return map;
const reviewRows: Array<{
sourceIssueId: string | null;
reviewIssueId: string;
reviewIdentifier: string | null;
status: string;
priority: string;
createdAt: Date;
updatedAt: Date;
}> = [];
for (const chunk of chunkList([...new Set(sourceIssueIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const rows = await dbOrTx
.select({
sourceIssueId: issues.originId,
reviewIssueId: issues.id,
reviewIdentifier: issues.identifier,
status: issues.status,
priority: issues.priority,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
inArray(issues.originId, chunk),
isNull(issues.hiddenAt),
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
),
);
reviewRows.push(...rows);
}
if (reviewRows.length === 0) return map;
const reviewIssueIds = reviewRows.map((row) => row.reviewIssueId);
const triggerByReviewIssueId = new Map<
string,
{ trigger: IssueProductivityReviewTrigger | null; noCommentStreak: number | null }
>();
for (const chunk of chunkList(reviewIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const detailRows = await dbOrTx
.select({
entityId: activityLog.entityId,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, chunk),
inArray(activityLog.action, PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS),
),
)
.orderBy(desc(activityLog.createdAt));
for (const row of detailRows as Array<{
entityId: string;
details: Record<string, unknown> | null;
createdAt: Date;
}>) {
if (triggerByReviewIssueId.has(row.entityId)) continue;
triggerByReviewIssueId.set(row.entityId, {
trigger: readProductivityReviewTrigger(row.details?.trigger),
noCommentStreak: readProductivityReviewStreak(row.details?.noCommentStreak),
});
}
}
for (const row of reviewRows) {
if (!row.sourceIssueId) continue;
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
map.set(row.sourceIssueId, {
reviewIssueId: row.reviewIssueId,
reviewIdentifier: row.reviewIdentifier,
status: row.status as IssueProductivityReview["status"],
priority: row.priority as IssueProductivityReview["priority"],
trigger: detail?.trigger ?? null,
noCommentStreak: detail?.noCommentStreak ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
return map;
}
async function listIssueBlockerAttentionMap(
dbOrTx: any,
companyId: string,
@@ -2108,7 +2232,10 @@ export function issueService(db: Db) {
]);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns);
const [blockerAttentionByIssueId, productivityReviewByIssueId] = await Promise.all([
listIssueBlockerAttentionMap(db, companyId, withRuns),
listIssueProductivityReviewMap(db, companyId, issueIds),
]);
if (!contextUserId) {
return withRuns.map((row) => {
@@ -2123,6 +2250,9 @@ export function issueService(db: Db) {
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
};
});
}
@@ -2141,6 +2271,9 @@ export function issueService(db: Db) {
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
@@ -2292,6 +2425,14 @@ export function issueService(db: Db) {
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
},
listProductivityReviews: async (
companyId: string,
sourceIssueIds: string[],
dbOrTx: any = db,
) => {
return listIssueProductivityReviewMap(dbOrTx, companyId, sourceIssueIds);
},
listWakeableBlockedDependents: async (blockerIssueId: string) => {
const blockerIssue = await db
.select({ id: issues.id, companyId: issues.companyId })
@@ -2458,7 +2599,9 @@ export function issueService(db: Db) {
parentId: parent.id,
projectId: issueData.projectId ?? parent.projectId,
goalId: issueData.goalId ?? parent.goalId,
requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0),
requestDepth: clampIssueRequestDepth(
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
),
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
inheritExecutionWorkspaceFromIssueId: parent.id,
});
@@ -2615,6 +2758,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
@@ -2700,6 +2844,9 @@ export function issueService(db: Db) {
...issueData,
updatedAt: new Date(),
};
if (issueData.requestDepth !== undefined) {
patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth);
}
const nextAssigneeAgentId =
issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
+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">