import type { ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Archive, Eye, Flag } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
recoveryChipLabel,
} from "../lib/recovery-display";
import {
formatRecoveryLineageSummary,
readRecoveryRetryLineage,
type RecoveryLivenessContext,
} from "../lib/recovery-lineage";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
import { Badge } from "@/components/ui/badge";
type UnreadState = "hidden" | "visible" | "fading";
interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
desktopTrailing?: ReactNode;
/**
* Optional pre-fetched external-object summary. Renders a compact severity
* marker before the rest of `desktopTrailing` on desktop only.
*/
externalObjectSummary?: ExternalObjectSummary | null;
trailingMeta?: ReactNode;
titleSuffix?: ReactNode;
titleClassName?: string;
checklistStepNumber?: number | string | null;
checklistCurrentStep?: boolean;
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
/** Pointer entered the row (used by list keyboard nav to track hover). */
onMouseEnter?: () => void;
/** Ancestor levels; renders that many vertical tree-guide slots (desktop). */
treeGuides?: number;
/**
* This row has its own collapse chevron sitting in the innermost guide
* column (a nested parent). Breaks the guide line there so the chevron is
* not crossed out by it.
*/
chevronInGuide?: boolean;
/** Opt in to a bottom divider on this row (default off; used by views that intentionally keep separators). */
showDivider?: boolean;
}
export function InboxArchiveButton({
onArchive,
disabled,
}: {
onArchive: () => void;
disabled?: boolean;
}) {
return (
);
}
export function IssueRow({
issue,
issueLinkState,
selected = false,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
mobileMeta,
desktopTrailing,
externalObjectSummary,
trailingMeta,
titleSuffix,
titleClassName,
checklistStepNumber = null,
checklistCurrentStep = false,
checklistDependencyChips,
checklistRowId,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
onMouseEnter,
treeGuides = 0,
chevronInGuide = false,
showDivider = false,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
// A row participates in the unread system whenever `unreadState` is supplied
// (inbox rows). It then reserves a fixed leading dot slot on all rows — read
// and unread alike — so the mark-read dot sits in the far-left gutter without
// shifting content, matching the sibling non-issue inbox rows.
const showUnreadSlot = unreadState != null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const unreadDotButton = (
);
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const productivityReview = issue.productivityReview ?? null;
const productivityReviewIndicator = productivityReview ? (
) : null;
const hasChecklistStep = checklistStepNumber !== null;
const checklistStep = hasChecklistStep ? (
{checklistStepNumber}.
) : null;
const recoveryAction = issue.activeRecoveryAction ?? null;
// The row already carries the issue's own scheduled retry, so the chip can tell a retry the
// scheduler is actually running from one whose due time simply passed.
const recoveryIndicator = recoveryAction
? renderRecoveryChip(recoveryAction, selected, { scheduledRetry: issue.scheduledRetry ?? null })
: null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
Blocked by parked work
) : null;
return (
rememberIssueDetailLocationState(issuePathId, detailState)}
className={cn(
// Overlay Link keeps ONLY positioning + focus ring so header controls
// stay clickable above it; visual washes belong on the root above.
"absolute inset-0 rounded-lg no-underline text-inherit focus-visible:z-10 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
)}
>
Open {identifier}: {issue.title}
{mobileLeading ?? }
{productivityReviewIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
{issue.title}{titleSuffix}
{checklistDependencyChips ? (
{checklistDependencyChips}
) : null}
{showUnreadSlot ? (
// Reserved leftmost dot gutter (desktop). Present on read and unread
// rows so the mark-read dot lives to the LEFT of any leading control
// (a parent's collapse caret, a tree guide) without indenting the row
// relative to its siblings, and aligns with the non-issue inbox rows
// that reserve the same w-4 slot.
{showUnreadDot ? unreadDotButton : null}
) : null}
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
// The innermost guide lands on THIS row's own chevron column; if
// the row has a chevron, break the line around it so it isn't
// crossed out.
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
// Tree guide: occupies the same flex slot as the parent's
// chevron column so the line lands under the parent's status
// column; stretched past the row padding so consecutive rows
// read as one continuous line.
{/* The connector drops from under the ancestor's STATUS icon,
not its chevron: the status column sits one level (w-4 slot
+ gap-2 = 2rem) right of this guide slot's left edge.
bg-background underlay: dark-mode --border is translucent,
so overlapping row segments would stack brighter without
an opaque base. */}
{gapForChevron ? (
// Two border segments centering a 14px (h-3.5) transparent
// gap for the row's own chevron.
) : (
)}
);
})
: null}
{desktopLeadingSpacer ? (
) : null}
{desktopMetaLeading ?? (
<>
{productivityReviewIndicator}
{checklistStep}
{identifier}
{parkedBlockerIndicator}
{recoveryIndicator}
>
)}
{mobileMeta ? (
<>
·
{mobileMeta}
>
) : null}
{(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? (
{onArchive ? (
) : null}
{externalObjectSummary ? (
) : null}
{desktopTrailing}
{trailingMeta ? (
{trailingMeta}
) : null}
) : null}
{showUnreadDot ? (
// Inbox rows reserve a mobile gutter on both read and unread rows. The
// full control stays inside overflow-clipping row containers while its
// absolute position avoids shifting or covering the leading control.
{unreadDotButton}
) : null}