import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { useState, type CSSProperties, 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, getStatusIconLabel } from "./StatusIcon"; import { hasAssignedBacklogBlocker } from "../lib/issue-blockers"; import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TaskRowColumnCells, useTaskListColumns, type TaskRowColumnValues } from "./TaskListColumns"; import { CellMarquee, HoveredCellContext, useCellMarqueeHover } from "./CellMarquee"; export type IssueRowUnreadState = "hidden" | "visible" | "fading"; export type IssueRowPresentation = "legacy" | "task"; export type IssueRowAppearance = "default" | "refreshed"; export type IssueRowDensity = "comfortable" | "compact"; export interface IssueRowProps { issue: Issue; issueLinkState?: unknown; selected?: boolean; /** Opt-in canonical collection layout. Legacy remains the default until each surface migrates. */ presentation?: IssueRowPresentation; /** Reviewed in Storybook before adoption; only applies to the canonical task presentation. */ appearance?: IssueRowAppearance; /** Refreshed task rows share their anatomy at either density. */ density?: IssueRowDensity; /** Opt-in complete column contract for refreshed task lists. */ columnValues?: TaskRowColumnValues; /** 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", appearance = "default", density = "comfortable", columnValues, 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 visibleColumns = useTaskListColumns(); const { hoveredCell, onMouseMove: handleCellHover, clearHoveredCell } = useCellMarqueeHover(); const [titleFocused, setTitleFocused] = useState(false); const issuePathId = issue.identifier ?? issue.id; const identifier = issue.identifier ?? issue.id.slice(0, 8); // Refreshed task rows communicate unread state through title typography alone. // Existing presentations retain their unread control and its reserved space. const showUnreadSlot = unreadState != null && !(presentation === "task" && appearance === "refreshed"); 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 = issue.interruption?.responsibility === "system" && issue.interruption.state !== "resolved" ? {issue.interruption.state === "working" ? "Thinking" : issue.interruption.state === "retry_scheduled" ? "Trying again soon" : "Retry available"} : 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"; const refreshed = appearance === "refreshed"; const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention); const statusIcon = ; const status = statusSlot ?? (refreshed ? ( rememberIssueDetailLocationState(issuePathId, detailState)} aria-label={`Open task ${identifier} (${statusLabel})`} className="task-row-status-link" data-slot="task-row-status" > {statusIcon} {statusLabel} ) : statusIcon); const taskLink = ( rememberIssueDetailLocationState(issuePathId, detailState)} onFocus={appearance === "refreshed" ? () => setTitleFocused(true) : undefined} onBlur={appearance === "refreshed" ? () => setTitleFocused(false) : undefined} onKeyDown={appearance === "refreshed" ? (event) => { if (event.key === "Escape") { clearHoveredCell(); setTitleFocused(false); } } : undefined} className={cn( "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", refreshed && "task-row-link", )} > Open {identifier}: {issue.title} ); return (
{ clearHoveredCell(); setTitleFocused(false); } : undefined} role={refreshed && columnValues ? "row" : undefined} data-slot="task-row" data-unread={isUnread ? "true" : undefined} data-appearance={refreshed ? "refreshed" : undefined} data-density={refreshed ? density : undefined} data-columns={refreshed && columnValues ? "all" : undefined} data-status-hidden={refreshed && columnValues && !visibleColumns.includes("task") ? "true" : undefined} data-title-hidden={refreshed && columnValues && !visibleColumns.includes("task") ? "true" : undefined} style={refreshed ? { "--task-row-depth": treeGuides } as CSSProperties : undefined} data-selected={selected ? "true" : undefined} data-current-step={checklistCurrentStep ? "true" : undefined} className={cn( "group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2", !refreshed && (showUnreadSlot ? "pl-4" : "pl-2 sm:pl-4"), "[&_button]:relative [&_button]:z-10", selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50", checklistCurrentStep && "bg-primary/5", refreshed && "task-row-refreshed", className, )} > {taskLink} {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} ); }