mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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:
1 parent
e7d32f19cd
commit
b779e43272
10 files changed
+458
-19
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -136,6 +136,8 @@ export type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user