Files
PaperClipAI/ui/src/components/IssueRow.tsx
T
DottaandPaperclip 83709f75eb fix(ui): hide task row retry status labels (#18017)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task lists and the inbox help people scan their work.
> - Shared task rows show system interruption messages beside task
titles.
> - These messages add detail that the person cannot act on from the
row.
> - This pull request removes the three system interruption labels from
those rows.
> - People can still open the task to inspect progress and use Retry.

## Linked Issues or Issue Description

Refs #16965, which introduced these labels. Related recovery report:
#17386. This change only removes row text and does not resolve that
report's retry behavior.

**What existing behavior does this improve?**

The shared task rows in task lists and the inbox.

**Current behavior**

Rows show “Retry available,” “Thinking,” or “Trying again soon” beside
the title for unresolved system interruptions.

**Proposed behavior**

Omit all three labels from shared task rows. Keep the existing
system-interruption guard so the row does not show a legacy recovery
chip in their place. Keep task status, other badges, and retry controls
inside the task.

**Reason and benefit**

People can scan task titles with less incidental execution text.

**Breaking changes**

The three row labels disappear. APIs, recovery behavior, and task
conversation controls stay the same.

## What Changed

- Remove the system interruption text from every shared task-row
presentation.
- Cover working, scheduled-retry, and failed interruptions in both row
presentations, including a legacy recovery action that must stay hidden.
- Record the expected row behavior and verification states in the task
feature map.

## Verification

- Passed: token gates, feature-map contract and reference checks, and
Git diff whitespace checks.
- Passed: 83 component tests in `IssueRow.test.tsx` and
`Inbox.test.tsx`, including the new regression cases; full `pnpm -r
typecheck`; full `pnpm build`.
- The full local `pnpm test:run` was attempted and stopped after 16
database-dependent failures. An independent disposable `initdb`
reproduced `FATAL: could not create semaphores: No space left on
device`. The host has exhausted its macOS System V semaphore capacity.
All required CI test lanes passed on the final commit and provide full
database-suite coverage. An earlier attempt also found an unbuilt plugin
SDK; building that existing dependency repaired collection before this
database failure.
- Review the working, scheduled-retry, and failed interruption branches.
Each returns no system label. The existing guard still suppresses legacy
recovery chips.
- Independent source review found no actionable code issues. Regression
cases check title navigation, removal of the three labels, and
suppression of replacement recovery chips.
- All required CI gates passed on commit
`dd1d7b08dbba8a10a95c5d24c4aea64479befe95`: typecheck, general and
serialized tests, browser suites, build, canary, and Runner checks.
- Greptile reviewed that commit at 5/5 with no open findings. The
optional Paperclip review also returned 5/5 with no findings; its check
status is neutral.

## Risks

- Low risk. This removes row text only. Task status, recovery records,
other badges, and task-level retry actions keep their current behavior.

## Model Used

OpenAI Codex, GPT-6 family. This session does not expose an exact model
ID or context-window size. The assistant used reasoning, shell tools,
and subagents for source review and validation.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 08:22:13 -05:00

642 lines
28 KiB
TypeScript

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, CircleAlert } 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;
/** Reserve collection geometry while optional diagnostics arrive. */
stableLayout?: boolean;
/** 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 (
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onArchive();
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onArchive();
}}
disabled={disabled}
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30",
compact ? "h-5 py-0" : "py-1",
)}
aria-label="Archive"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
);
}
export function IssueRow({
issue,
issueLinkState,
selected = false,
presentation = "legacy",
appearance = "default",
density = "comfortable",
stableLayout = false,
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 = (
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}
}}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>
</button>
);
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const hasChecklistStep = checklistStepNumber !== null;
const checklistStep = hasChecklistStep ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
{checklistStepNumber}.
</span>
) : 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"
? null
: recoveryAction && !requiresExecutionReconciliation(recoveryAction.cause)
? renderRecoveryChip(recoveryAction, selected, { scheduledRetry: issue.scheduledRetry ?? null })
: null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
<Badge variant="outline"
data-testid="issue-row-parked-blocker"
className="[&>svg]:size-2.5 ml-1.5 gap-0.5 border-amber-500/60 bg-amber-500/15 text-(length:--text-nano) text-amber-700 dark:text-amber-300"
title="Blocked by parked work — at least one assigned blocker is in backlog and will not wake its assignee."
>
<Flag className="h-2.5 w-2.5" aria-hidden />
Blocked by parked work
</Badge>
) : 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 = <StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />;
const status = statusSlot ?? (refreshed ? (
<Tooltip>
<TooltipTrigger asChild>
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
aria-label={`Open task ${identifier} (${statusLabel})`}
className="task-row-status-link"
data-slot="task-row-status"
>
{statusIcon}
</Link>
</TooltipTrigger>
<TooltipContent className="task-row-status-tooltip">{statusLabel}</TooltipContent>
</Tooltip>
) : statusIcon);
const taskLink = (
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => 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",
)}
>
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
);
return (
<HoveredCellContext.Provider value={hoveredCell}>
<div
onMouseEnter={onMouseEnter}
onMouseMove={refreshed ? handleCellHover : undefined}
onMouseLeave={refreshed ? () => { clearHoveredCell(); setTitleFocused(false); } : undefined}
role={refreshed && columnValues ? "row" : undefined}
data-slot="task-row"
data-issue-id={issue.id}
data-issue-row-details={issue.id}
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",
stableLayout && !refreshed && "task-row-stable",
className,
)}
>
{taskLink}
{showUnreadSlot ? (
<span
data-testid="issue-row-unread-slot"
className="absolute left-0 top-3 inline-flex h-4 w-4 items-center justify-center sm:top-1/2 sm:-translate-y-1/2"
>
{showUnreadDot ? unreadDotButton : null}
</span>
) : null}
<span role={refreshed && columnValues ? "cell" : undefined} data-slot="task-row-leading" data-marquee-cell={refreshed ? "task" : undefined} className="flex shrink-0 items-start self-stretch gap-1 pt-px sm:items-center sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
<span
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
data-chevron-gap={gapForChevron ? "true" : undefined}
aria-hidden="true"
className="relative block w-4 shrink-0 self-stretch"
>
<span
data-slot="task-row-tree-connector"
className="absolute -inset-y-3 left-7 w-px bg-background"
>
{gapForChevron ? (
<span className="absolute inset-0 flex flex-col">
<span data-slot="task-row-tree-line" className="flex-1 bg-border" />
<span data-slot="task-row-tree-gap" className="h-3.5 shrink-0" />
<span data-slot="task-row-tree-line" className="flex-1 bg-border" />
</span>
) : (
<span data-slot="task-row-tree-line" className="absolute inset-0 bg-border" />
)}
</span>
</span>
);
})
: null}
{refreshed ? (
<span data-slot="task-row-leading-control">{leadingControl}</span>
) : leadingControl}
{refreshed && columnValues && !visibleColumns.includes("task") ? null : status}
{parkedBlockerIndicator}
</span>
<span role={refreshed && columnValues ? "cell" : undefined} data-slot="task-row-content" data-marquee-cell={refreshed ? "task" : undefined} className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-baseline gap-1.5 sm:items-center">
{refreshed ? (
<CellMarquee cell="task" active={titleFocused} slot="task-row-title" contentSlot="task-row-title-text" tabbable={false} className={cn("min-w-0", isUnread && "font-semibold", titleClassName)}>
{issue.title}{titleSuffix}
</CellMarquee>
) : <span
data-slot="task-row-title"
className={cn(
"min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none",
isUnread && "font-semibold",
titleClassName,
)}
>
{issue.title}{titleSuffix}
</span>}
{stableLayout ? (
<span data-slot="task-row-recovery">
<span className="hidden sm:inline">{recoveryIndicator}</span>
{recoveryIndicator ? (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} aria-label="Task recovery status" className="relative z-10 inline-flex text-muted-foreground sm:hidden">
<CircleAlert className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent>{recoveryIndicator}</TooltipContent>
</Tooltip>
) : null}
</span>
) : recoveryIndicator}
{mobileTitleMeta ? (
<span className="ml-auto shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground sm:hidden">
{mobileTitleMeta}
</span>
) : null}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>
) : null}
{mobileMeta ? (
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
) : null}
</span>
<span
data-slot="task-row-trailing"
className="ml-auto hidden min-w-0 shrink-0 items-center gap-2 sm:flex"
>
{refreshed && columnValues ? (
<>
<TaskRowColumnCells identifier={identifier} values={columnValues} />
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
</>
) : (
<>
{stableLayout ? (
<span data-slot="task-row-external"><ExternalObjectStatusSummary summary={externalObjectSummary} compact /></span>
) : externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{metadata ? <span data-slot="task-row-metadata" data-marquee-cell={refreshed ? "metadata" : undefined} className="min-w-0">{refreshed ? <CellMarquee cell="metadata">{metadata}</CellMarquee> : metadata}</span> : null}
{desktopTrailing}
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
{showIdentifier ? (
<span data-slot="task-row-identifier" data-marquee-cell={refreshed ? "identifier" : undefined} className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{refreshed ? <CellMarquee cell="identifier">{identifier}</CellMarquee> : identifier}
</span>
) : null}
{trailingMeta ? (
<span data-slot="task-row-timestamp" data-marquee-cell={refreshed ? "timestamp" : undefined} className="w-24 shrink-0 truncate text-right text-xs text-muted-foreground">
{refreshed ? <CellMarquee cell="timestamp">{trailingMeta}</CellMarquee> : trailingMeta}
</span>
) : null}
</>
)}
</span>
</div>
</HoveredCellContext.Provider>
);
}
return (
<div
data-issue-row-details={issue.id}
onMouseEnter={onMouseEnter}
className={cn(
// No color transition on the row band: hover/selection must snap
// instantly. A fade (transition-colors) leaves a trail of fading bands
// when scrubbing the mouse fast across the list.
"group relative flex items-start gap-2 rounded-lg py-2.5 pr-3 text-sm no-underline text-inherit sm:items-center sm:py-2 sm:pl-1",
showUnreadSlot ? "pl-4" : "pl-2",
"[&_button]:relative [&_button]:z-10",
// Divider + hover/selected/checklist wash live on the ROOT row band so
// the tint paints BEHIND the content and `last:border-b-0` matches the
// real last row. Keeping these on the overlay Link (PR #10526) made the
// last row keep its border and the hover wash paint over the text.
showDivider && "border-b border-border last:border-b-0",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
checklistCurrentStep ? "bg-primary/5" : null,
className,
)}
>
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => 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",
)}
>
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span data-slot="task-row-title-cluster" className="flex min-w-0 items-start gap-1.5 sm:order-2 sm:flex-1 sm:items-center">
<span
data-slot="task-row-title"
className={cn("min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none", titleClassName)}
>
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1 sm:order-3 sm:ml-(--sz-calc-13)">
{checklistDependencyChips}
</span>
) : null}
<span className="flex items-center gap-2 self-stretch sm:order-1 sm:shrink-0">
{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.
<span
data-testid="issue-row-unread-slot"
className="hidden h-4 w-4 shrink-0 items-center justify-center self-center sm:inline-flex"
>
{showUnreadDot ? unreadDotButton : null}
</span>
) : 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.
<span key={`guide-${level}`} aria-hidden="true" className="relative hidden w-4 shrink-0 self-stretch sm:block">
{/* 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. */}
<span className="absolute -inset-y-3 left-8 w-px bg-background">
{gapForChevron ? (
// Two border segments centering a 14px (h-3.5) transparent
// gap for the row's own chevron.
<span className="absolute inset-0 flex flex-col">
<span className="flex-1 bg-border" />
<span className="h-3.5 shrink-0" />
<span className="flex-1 bg-border" />
</span>
) : (
<span className="absolute inset-0 bg-border" />
)}
</span>
</span>
);
})
: null}
{desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" />
) : null}
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
{parkedBlockerIndicator}
</>
)}
{mobileMeta ? (
<>
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
</>
) : null}
</span>
</span>
{(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{onArchive ? (
<InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} />
) : null}
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : 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.
<span className="absolute left-0 top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center sm:hidden">
{unreadDotButton}
</span>
) : null}
</div>
);
}
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 (
<Badge variant="outline"
data-testid="issue-row-recovery-indicator"
data-recovery-state={state}
data-recovery-kind={action.kind}
data-recovery-lane={lineage?.lane}
role="status"
aria-label={detail ? `${label} — ${detail}` : label}
className={cn(
"shrink-0 gap-0.5 text-(length:--text-nano)",
tone.className,
selected ? "!border-muted-foreground !text-muted-foreground" : null,
)}
title={detail
? `${label} — ${detail}. Open the source task to act.`
: `${label} — open the source task to act.`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{label}
</Badge>
);
}