import { requiresExecutionReconciliation } from "@paperclipai/shared"; import type { ReactNode } from "react"; import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { Archive, 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 { hasAssignedBacklogBlocker } from "../lib/issue-blockers"; import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary"; import { Badge } from "@/components/ui/badge"; export type IssueRowUnreadState = "hidden" | "visible" | "fading"; export type IssueRowPresentation = "legacy" | "task"; export interface IssueRowProps { issue: Issue; issueLinkState?: unknown; selected?: boolean; /** Opt-in canonical collection layout. Legacy remains the default until each surface migrates. */ presentation?: IssueRowPresentation; /** Interactive disclosure or selection control before the canonical status glyph. */ leadingControl?: ReactNode; /** Optional status override; defaults to the task's shared StatusIcon. */ statusSlot?: ReactNode; /** Stable metadata slot before the task's optional collection columns. */ metadata?: ReactNode; /** Stable interactive action slot before the identifier and timestamp columns. */ actions?: ReactNode; /** Controls the canonical trailing identifier without affecting legacy layouts. */ showIdentifier?: boolean; mobileLeading?: ReactNode; desktopMetaLeading?: ReactNode; desktopLeadingSpacer?: boolean; mobileMeta?: ReactNode; /** Compact mobile timestamp beside the title in canonical task lists. */ mobileTitleMeta?: 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?: IssueRowUnreadState | 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 nested row has its own collapse chevron aligned with the innermost * guide. Breaks the guide line there so the chevron is not crossed out. */ chevronInGuide?: boolean; /** Legacy-only opt in to a bottom divider; canonical task rows stay divider-free. */ showDivider?: boolean; } export function InboxArchiveButton({ onArchive, disabled, compact = false, }: { onArchive: () => void; disabled?: boolean; compact?: boolean; }) { return ( ); } export function IssueRow({ issue, issueLinkState, selected = false, presentation = "legacy", leadingControl, statusSlot, metadata, actions, showIdentifier = true, mobileLeading, desktopMetaLeading, desktopLeadingSpacer = false, mobileMeta, mobileTitleMeta, 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. // Canonical rows overlay this affordance in their shared gutter, while legacy // inbox rows retain their reserved slot until that presentation is migrated. 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 hasChecklistStep = checklistStepNumber !== null; const checklistStep = hasChecklistStep ? ( ) : 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 && !requiresExecutionReconciliation(recoveryAction.cause) ? renderRecoveryChip(recoveryAction, selected, { scheduledRetry: issue.scheduledRetry ?? null }) : null; const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? ( Blocked by parked work ) : null; if (presentation === "task") { const isUnread = unreadState === "visible" || unreadState === "fading"; return (
rememberIssueDetailLocationState(issuePathId, detailState)} className="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} {showUnreadSlot ? ( {showUnreadDot ? unreadDotButton : null} ) : null} {treeGuides > 0 ? Array.from({ length: treeGuides }, (_, level) => { const gapForChevron = chevronInGuide && level === treeGuides - 1; return (
); } 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 ?? } {parkedBlockerIndicator} {issue.title}{titleSuffix} {recoveryIndicator} {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.
); } function renderRecoveryChip( action: IssueRecoveryAction, selected: boolean, liveness: RecoveryLivenessContext, ): ReactNode { const state = deriveActiveRecoveryDisplayState(action, liveness); if (!state) return null; const tone = RECOVERY_CHIP_DEFAULT_TONE[state]; const Icon = tone.icon; const lineage = readRecoveryRetryLineage(action, liveness); const label = recoveryChipLabel(state, action.kind, lineage); const detail = lineage ? formatRecoveryLineageSummary(lineage) : null; return ( {label} ); }