mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01: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
8a34401d80
commit
dd3aa3569f
4 files changed
+228
-4
No files matched your search
@@ -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