Files
PaperClipAI/ui/src/components/IssueRow.tsx
T
scotttongandClaude Fable 5 53d09d4c34 fix(ui): inbox/task list parity, nesting alignment, hover perf, and routine detail polish (#9317)
## Thinking Path

> - Paperclip's UI is governed by the design system merged in #9134 and
the component convergence in #9240 — one Card, one Badge, one nav row,
one `IssueRow`, a single multiplicative radius ladder.
> - With those primitives in place, the remaining rough edges were
interaction and alignment details on the surfaces people use every day:
the inbox, the task list, the sidebar, and the routine/task detail
pages.
> - Each item here was reported from live use and fixed against a
running instance, then verified by measurement (pixel alignment, frame
timing) rather than by eye alone.
> - The result is that the inbox and task lists now behave as one system
(same hover, keyboard nav, tree-guide, and archive language), list
nesting reads correctly, list hover is smooth, and the routine detail
page scrolls and aligns like the rest of the app.

## Linked Issues or Issue Description

No public GitHub issue exists for this work; describing per the feature
template.

- **Problem**: after the design-system foundation (#9134) and component
convergence (#9240), the inbox/task lists still had interaction and
alignment gaps — hover lag on long lists, keyboard navigation that only
partly matched between the two lists, workspace/parent nesting whose
guides and chevrons didn't line up, an inbox that sat offset from the
task list, and a routine detail page with an odd double-scroll and a
bespoke sub-nav.
- **Proposed behavior**: the inbox and task lists share one interaction
contract (hover, keyboard nav, collapse, archive), list nesting aligns
to the status column with clean chevrons, list hover is CSS-only (no
per-hover re-render), and the routine detail page uses a fixed
header/sub-nav with a single scrolling content region and a sub-nav that
matches the primary nav.

## What Changed

- **List hover performance**: hover is now painted purely by CSS
`:hover` and records the hovered row in a ref, instead of writing
list-selection state on every `mouseenter` (which re-rendered 100–300
non-memoized rows per hover). Keyboard nav reads the ref so it still
continues from the hovered row; the keyboard band clears on the first
real mouse move so hover and keyboard selection never show two bands at
once. The row's `transition-colors` fade was removed so the highlight
snaps (no comet-tail). Measured on a 100-row scrub: worst frame **333 ms
→ 33 ms**, long frames (>50 ms) **12 → 0**, avg **41 → 60 fps**.
- **Inbox ↔ task-list parity**: keyboard navigation works on every inbox
tab (archive/read keys stay scoped to the archivable tab); group headers
and parent tasks collapse/expand with the arrow keys in both lists; the
task list gains the same j/k / arrows / Enter selection model as the
inbox; hover selection bands match; the `g` then `i` go-to-inbox chord
works app-wide.
- **List nesting & alignment**: the workspace group-header chevron lines
up exactly with the task chevrons below it (both lists); the
parent→child connector line drops from under the parent's **status
icon** rather than its chevron and breaks with a 14px gap around a
nested row's own chevron; and inbox rows line up with the task list
(read rows no longer reserve a mark-read column).
- **Inbox archive affordance**: moved from a bare `x` left of the status
icon to an `Archive` icon + label button on the right (before the
timestamp), revealed on row hover; the left slot now carries only the
unread dot; swipe-to-archive is unchanged.
- **Sidebar**: when no agent has a live run, the AGENTS section shows 3
recent agents (was 5) plus "See all agents"; the working-agents view is
unchanged.
- **Routine detail page**: the layout is bounded to the main scroll area
so the header (Run now + automation toggle) and the sub-nav stay fixed
and only the section content scrolls (was a page-level scroll competing
with a `sticky` sub-nav). The sub-nav items adopt the primary nav's
rhythm — row padding/height, inset rounded pill, type scale, and 16px
icons — and its background matches the main nav.
- **Task detail**: dropped the redundant `🔵` prefix the breadcrumb added
for live/in-progress tasks; the status glyph already conveys that state.

## Verification

- `pnpm check:token-gates` → 3/3 CLEAN
- `pnpm typecheck` → green (all packages)
- `cd ui && npx vitest run` → 2255/2255 (assertions updated in lockstep
where behavior changed)
- `pnpm --filter @paperclipai/ui build` → exit 0
- Storybook visual regression: run locally throughout (the
baseline-manifest archive is still unpublished, so CI cannot run this
suite — pre-existing condition from #9134). Every visible delta was
reviewed against a live instance and, where it was intentional (nesting
alignment, routine sub-nav restyle, unread-row shift), the affected
snapshots were re-baselined locally.
- Manual/measured: inbox + task lists (grouped and nested, light +
dark), hover-scrub frame timing, keyboard navigation, and the routine
detail scroll/alignment were exercised on a running instance.

## Risks

- Behavior-and-alignment changes concentrated in `IssueRow` /
`IssuesList` / `Inbox` (the shared task-row surfaces). The riskiest area
— the hover/keyboard-selection model — is covered by unit tests (updated
in lockstep) and was measured and driven live.
- The routine detail scroll change restructures that page's layout
container; verified the page's own scroll stays fixed while only the
section content scrolls.
- The visual suite cannot yet run in CI (unpublished baseline archive —
pre-existing); snapshot coverage is local-only until that lands.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic
coding with tool use (file editing, test execution, Playwright
measurement/screenshot verification); extended thinking enabled.

## 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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:25:48 -07:00

327 lines
13 KiB
TypeScript

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 { 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;
/** Suppress the row divider (parents with expanded children keep visual attachment to their subtree). */
hideDivider?: boolean;
}
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,
hideDivider = false,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
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">
{checklistStepNumber}.
</span>
) : null;
const recoveryAction = issue.activeRecoveryAction ?? null;
const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : 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;
return (
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
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 flex items-start gap-2 rounded-lg py-2.5 pl-2 pr-3 text-sm no-underline text-inherit sm:items-center sm:py-2 sm:pl-1",
!hideDivider && "border-b border-border last:border-b-0",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
checklistCurrentStep ? "border-l-2 border-l-primary bg-primary/5 pl-(--sz-calc-11) sm:pl-(--sz-calc-12)" : null,
className,
)}
>
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{productivityReviewIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</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)}>
{issue.title}{titleSuffix}
</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">
{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} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
{productivityReviewIndicator}
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
{parkedBlockerIndicator}
{recoveryIndicator}
</>
)}
{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 ? (
<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={archiveDisabled}
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 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"
aria-label="Archive"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
) : null}
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{showUnreadDot ? (
// Only unread rows reserve this leading mark-read column; read rows
// omit it entirely so their content lines up with the tasks list
// (which has no such column). Archive lives on the right now.
<span className="order-first inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
<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>
</span>
) : null}
</Link>
);
}
function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode {
const state = deriveActiveRecoveryDisplayState(action);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
const label = recoveryChipLabel(state, action.kind);
return (
<Badge variant="outline"
data-testid="issue-row-recovery-indicator"
data-recovery-state={state}
data-recovery-kind={action.kind}
role="status"
aria-label={label}
className={cn(
"ml-1.5 gap-0.5 text-(length:--text-nano)",
tone.className,
selected ? "!border-muted-foreground !text-muted-foreground" : null,
)}
title={`${label} — open the source task to act.`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{label}
</Badge>
);
}