import { memo, useState, type KeyboardEvent, type ReactNode } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AlarmClock, CalendarClock, ChevronDown, ChevronUp, ExternalLink, GraduationCap, Loader2, MoreHorizontal, RotateCcw, X, } from "lucide-react"; import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { accessApi } from "../api/access"; import { approvalsApi } from "../api/approvals"; import { issuesApi } from "../api/issues"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { attentionDetailImages, attentionDetailLine, attentionImageUrl, attentionStatus, attentionTaskRef, decideByLabel, isInlineResolvable, sourceMeta, } from "../lib/attention"; import { isTrainable } from "../lib/decisionTraining"; import { cn, relativeTime } from "../lib/utils"; import { DecisionTriageStrip } from "./DecisionTriageStrip"; import { StatusGlyph } from "./StatusGlyph"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Textarea } from "./ui/textarea"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { AttentionInteractionResolver } from "./AttentionInteractionResolver"; import { DecisionResolver } from "./DecisionResolver"; const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; // Decision-action buttons: a comfortable tap target when the row is narrow // (h-9 / text-sm), shrinking back to the dense pill (h-6 / text-xs) once the // row's own container is wide enough (`@xl` ≈ 576px). Container-query driven so // the row also reflows correctly inside narrow side panels, not just on phones. const ACTION_BTN = "h-9 gap-1.5 px-3 text-sm @xl:h-6 @xl:gap-1 @xl:px-2 @xl:text-xs"; /** Tomorrow at 9am local time. */ function tomorrowMorningIso(): string { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0); return d.toISOString(); } /** Snooze presets, resolved to a future ISO timestamp at click time. */ const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [ { label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() }, { label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() }, { label: "Tomorrow morning", resolve: tomorrowMorningIso }, { label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() }, ]; interface AttentionQueueRowProps { item: AttentionItem; companyId: string; expanded: boolean; /** Receives the row's item so the parent can pass one stable callback for every row. */ onToggleExpand: (item: AttentionItem) => void; onDismiss: (item: AttentionItem) => void; onSnooze?: (item: AttentionItem, snoozedUntil: string) => void; /** Open the decision-training drawer for this row (create or view). */ onTrain?: (item: AttentionItem) => void; /** Restore a snoozed/dismissed row (curtain variant only). */ onRestore?: (item: AttentionItem) => void; /** "active" renders the live queue row; "hidden" renders a curtain row. */ variant?: "active" | "hidden"; agentMap?: Map; /** Company agents, for the triage strip's route-to-agent picker. */ agents?: Agent[]; /** Render the per-card triage strip (queue/decide-by/snooze/route) when expanded. */ showTriage?: boolean; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; selected?: boolean; } /** * Memoized (PAP-13784): the queue renders every feed row in one flat list, so * without memo a single keyboard-selection or expand toggle re-renders every * row (each carrying a Radix dropdown + mutation). All props are stable or * primitive; `item` identity is preserved across refetches by react-query's * structural sharing. */ export const AttentionQueueRow = memo(function AttentionQueueRow({ item, companyId, expanded, onToggleExpand, onDismiss, onSnooze, onTrain, onRestore, variant = "active", agentMap, agents, showTriage = false, currentUserId, userLabelMap, selected = false, }: AttentionQueueRowProps) { const meta = sourceMeta(item.sourceKind); // Colour + glyph are borrowed wholesale from the task status system, so a // blocking decision reads exactly like a blocked task (DESIGN.md principle 5). const status = attentionStatus(item); // The task this row belongs to, whichever field the feed put it in. const taskRef = attentionTaskRef(item); const isHidden = variant === "hidden"; const inline = !isHidden && isInlineResolvable(item); const href = item.subject.href; const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null; const detailLine = attentionDetailLine(item) ?? item.whyNow; const images = attentionDetailImages(item); const hasImages = images.length > 0; // The issue (or source) this row points at — used as the target for the // "n more" affordance in the expanded gallery. const issueHref = item.relatedIssue?.href ?? href; // Inline-resolvable active rows expand to reveal their resolver; rows with // images expand to reveal a larger gallery (PAP-13544); triage-enabled rows // expand to reveal the per-card triage strip (PAP-16032 §4.5). Any of these // gives a header/thumbnail click somewhere to go. Non-inline, image-less rows // with no triage keep the explicit Open button and never toggle on a stray click. const triageEnabled = showTriage && !isHidden; const expandable = inline || (!isHidden && hasImages) || triageEnabled; // Any issue-anchored approval or interaction is // trainable at any time (pending or resolved). Trained/untrained renders // purely from the feed's `trainingExampleId` — no per-row fetch. const trainable = !isHidden && !!onTrain && isTrainable(item); const trained = item.trainingExampleId != null; const activate = () => { if (expandable) onToggleExpand(item); }; const onHeaderKeyDown = (e: KeyboardEvent) => { if (!expandable) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggleExpand(item); } }; // Which rows contribute an action bar. Inline rows carry compact decision // verbs; deep-link rows carry an Open button; curtain rows carry Restore. const compactActions = !isHidden ? collectCompactActions(item) : []; const showOpen = !inline && !!href; const showRestore = isHidden && !!onRestore; // An expanded inline row hands its footer to the resolver, which owns the // decision verbs — so the toggle rides alongside them on one row rather than // stranding a lone "See less" under the buttons. That makes the collapsed // footer a swap rather than a survivor, so it crossfades with the panel. const hasCollapsedOnlyContent = hasImages || inline; // Disclosure control. Now the row's only expand affordance: it names what it // does instead of leaving a bare chevron to be decoded, and it sits at the // bottom-left where the eye lands after reading the row. const toggle = expandable ? ( ) : null; /** * The row's action bar: disclosure on the left, decision verbs on the right. * Rendered either inside the collapsed-only cluster (inline rows, where the * resolver takes it over once expanded) or as a standing sibling (everything * else). `compact` is false for the standing copy so an expanded row does not * show collapsed verbs beside the panel's own. */ const renderFooter = ({ compact }: { compact: boolean }) => { const showCompact = compactActions.length > 0 && (compact || !expanded); if (!toggle && !showCompact && !showOpen && !showRestore) return null; return (
{toggle ?? }
{showCompact && ( onToggleExpand(item)} /> )} {showOpen && ( )} {showRestore && ( )}
); }; return (
{/* Meta band: one breadcrumb of identity on the left (kind → task → project), recency + overflow on the right. Not part of the clickable headline, so the menu never toggles it. */}
{meta.label} {taskRef && ( <> e.stopPropagation()} > {taskRef.identifier} )} {item.decideBy && ( <> {decideByLabel(item.decideBy)} {decideByProvenance(item) && ( · set by {decideByProvenance(item)} )} )} {trainable && trained && ( )}
{isHidden && snoozedUntil ? ( Reappears {reappearLabel(snoozedUntil)} ) : ( {relativeTime(item.activityAt)} )} {!isHidden && ( {/* Training moved off the header strip (which now carries only recency + overflow) but keeps its testids so the affordance is still addressable. */} {trainable && ( onTrain?.(item)} > {trained ? "View training example" : "Train this decision"} )} {onSnooze && onSnooze(item, iso)} />} onDismiss(item)}> Dismiss {href && ( <> Open source )} )}
{/* Headline — the primary expand target for inline rows. Title wraps to two lines instead of truncating to a sliver on narrow screens. */}
{item.subject.title ?? meta.label}

{detailLine}

{/* Collapsed-only content. It has no counterpart to morph into — the thumbnail strip becomes a full gallery, and an inline row's footer is replaced by the resolver's own — so it rides an inverse disclosure and crossfades against the panel below: this shrinks and fades out on the same tokens as that grows and fades in, instead of popping. */} {hasCollapsedOnlyContent && (
{hasImages && } {inline && renderFooter({ compact: true })}
)} {/* The disclosure panel. Collapsible measures the panel and publishes its height, so the card grows and shrinks to a real number rather than snapping open. `contents` keeps the Root out of the layout, so a collapsed row does not pay a flex gap for an empty wrapper — and once the exit finishes Radix unmounts the panel, so a collapsed row is not left with a live resolver behind it. */} onToggleExpand(item)} className="contents">
{hasImages && } {triageEnabled && } {inline && ( )}
{/* A non-inline row keeps one footer across both states — its Open or Restore button and its toggle are the same control either way, so it stays put rather than crossfading with itself. */} {!inline && renderFooter({ compact: false })}
); }); /** * "·" between eyebrow segments. * * The eyebrow is a flat list of two facts (decision kind, task key), not a * hierarchy, so a middle dot reads more honestly than the "/" this started as — * a slash implies containment that the two segments do not have. */ function EyebrowSeparator() { return ( · ); } type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision"; function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null { if (item.sourceKind === "approval" && (verbId === "approve" || verbId === "reject" || verbId === "request_revision")) { return verbId; } if (item.sourceKind === "join_request" && (verbId === "approve" || verbId === "reject")) { return verbId; } if ( item.sourceKind === "issue_thread_interaction" && item.subject.metadata?.kind === "request_confirmation" && (verbId === "accept" || verbId === "reject") ) { return verbId; } return null; } /** * Weight used to order a decision's verbs. The affirmative verb always lands * rightmost — the same place in every row, collapsed or expanded — so the * operator's aim never has to move with the verb list. */ const VERB_ORDER: Record<"outline" | "destructive" | "default", number> = { outline: 0, destructive: 1, default: 2, }; interface CompactAction { action: CompactDecisionAction; label: string; id: string; description: string; } /** The compact accept/reject verbs a collapsed row can resolve in place. */ function collectCompactActions(item: AttentionItem): CompactAction[] { return item.decisionVerbs .slice(0, 3) .flatMap((verb) => { const action = compactDecisionAction(item, verb.id); return action ? [{ action, label: verb.label, id: verb.id, description: verb.description ?? "" }] : []; }) .sort((a, b) => VERB_ORDER[decisionVerbVariant(a)] - VERB_ORDER[decisionVerbVariant(b)]); } function CompactDecisionActions({ item, companyId, onOpen, }: { item: AttentionItem; companyId: string; onOpen: () => void; }) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const actions = collectCompactActions(item); const decision = useMutation({ mutationFn: (action: CompactDecisionAction) => { if (item.sourceKind === "approval") { if (action === "approve") return approvalsApi.approve(item.subject.id); if (action === "reject") return approvalsApi.reject(item.subject.id); return approvalsApi.requestRevision(item.subject.id); } if (item.sourceKind === "join_request") { return action === "approve" ? accessApi.approveJoinRequest(companyId, item.subject.id) : accessApi.rejectJoinRequest(companyId, item.subject.id); } if (item.sourceKind === "issue_thread_interaction") { const issueId = item.subject.metadata?.issueId; if (typeof issueId !== "string") throw new Error("Missing issue reference for this decision."); if (action === "accept") return issuesApi.acceptInteraction(issueId, item.subject.id); return issuesApi.rejectInteraction(issueId, item.subject.id); } throw new Error("This decision must be completed from its detail view."); }, onSuccess: (_result, action) => { queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); if (item.sourceKind === "approval") { queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); } else { queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) }); } pushToast({ title: compactDecisionSuccessLabel(item.sourceKind, action), tone: "success", }); }, onError: (error, action) => { pushToast({ title: `Could not ${decisionLabel(action)}`, body: error instanceof Error ? error.message : "Please try again.", tone: "error", }); }, }); if (actions.length === 0) return null; return (
{actions.map(({ action, id, label, description }) => ( ))}
); } function decisionLabel(action: CompactDecisionAction): string { if (action === "request_revision") return "sent for revision"; if (action === "accept" || action === "approve") return "approved"; return "rejected"; } function compactDecisionSuccessLabel(sourceKind: AttentionItem["sourceKind"], action: CompactDecisionAction): string { if (sourceKind === "approval") return `Approval ${decisionLabel(action)}`; if (sourceKind === "join_request") return `Join request ${decisionLabel(action)}`; return action === "accept" ? "Confirmation accepted" : "Confirmation declined"; } function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" { const text = `${verb.label} ${verb.description ?? ""}`.toLowerCase(); if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive"; if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default"; return "outline"; } /** Square screenshot thumbnails at the right of the description (plan §10). */ function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) { const visible = images.slice(0, 3); const extra = images.length - visible.length; return (
{visible.map((img, index) => ( {img.alt ))}
{extra > 0 && ( +{extra} )}
); } /** * Larger image gallery shown when a row is expanded (PAP-13544). Shows the * first three screenshots at a readable size; if more exist, an "n more" tile * links through to the issue where the full set lives. */ function ExpandedImages({ images, issueHref }: { images: AttentionDetailImage[]; issueHref: string | null }) { const visible = images.slice(0, 3); const extra = images.length - visible.length; return (
{visible.map((img, index) => { const src = attentionImageUrl(img.assetId); const key = `${img.assetId}-${index}`; const image = ( {img.alt ); return issueHref ? ( e.stopPropagation()} > {image} ) : ( {image} ); })} {extra > 0 && (issueHref ? ( e.stopPropagation()} className="flex h-32 w-24 flex-col items-center justify-center rounded-md border border-dashed border-border bg-muted/40 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none" > {extra} more View issue ) : ( {extra} more ))}
); } /** Snooze submenu: presets + a custom date-time (plan §6). */ function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) { const [customValue, setCustomValue] = useState(""); const applyCustom = () => { if (!customValue) return; const ts = new Date(customValue); if (Number.isNaN(ts.getTime())) return; onSnooze(ts.toISOString()); }; return ( Snooze {SNOOZE_PRESETS.map((preset) => ( onSnooze(preset.resolve())}> {preset.label} ))} {/* Custom picker: a non-menu-item region so interacting with the input doesn't close the menu (guard keydown/select against Radix typeahead). */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > Custom setCustomValue(e.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs" />
); } /** * Who set this row's decide-by deadline, for the card's provenance line * ("· set by Prioritizer"). Returns null when unattributed so the chip shows * the deadline alone rather than a hollow "set by". */ function decideByProvenance(item: AttentionItem): string | null { const attribution = item.decideByAttribution; if (!attribution) return null; if (attribution.type === "agent") return attribution.agentName ?? "an agent"; return "you"; } /** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */ function reappearLabel(snoozedUntil: string): string { const diffMs = new Date(snoozedUntil).getTime() - Date.now(); if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon"; const diffMin = Math.round(diffMs / 60000); if (diffMin < 60) return `in ${diffMin}m`; const diffHr = Math.round(diffMin / 60); if (diffHr < 24) return `in ${diffHr}h`; const diffDay = Math.round(diffHr / 24); return `in ${diffDay}d`; } /** * Expanded-row content. Resolvers that own their decision verbs also render the * row's footer, so the disclosure toggle (`toggle`) sits on the same line as the * buttons. The issue-thread interaction card keeps its verbs internally — it is * shared with the issue thread surface — so there the toggle gets its own row. */ function InlineResolver({ item, companyId, agentMap, currentUserId, userLabelMap, toggle, }: { item: AttentionItem; companyId: string; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; toggle: ReactNode; }) { if (item.sourceKind === "decision") { return ( ); } if (item.sourceKind === "issue_thread_interaction") { const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id; if (!issueId) { return

Missing issue reference for this decision.

; } return ( <> {toggle &&
{toggle}
} ); } if (item.sourceKind === "approval") { return ; } if (item.sourceKind === "join_request") { return ; } return null; } /** Footer shared by the resolvers that own their verbs: toggle left, verbs right. */ function ResolverFooter({ toggle, children }: { toggle: ReactNode; children: ReactNode }) { return (
{toggle ?? }
{children}
); } function ApprovalResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) { const queryClient = useQueryClient(); const [note, setNote] = useState(""); const invalidate = () => { queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); }; const approve = useMutation({ mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined), onSuccess: invalidate, }); const reject = useMutation({ mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined), onSuccess: invalidate, }); const revise = useMutation({ mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined), onSuccess: invalidate, }); const pending = approve.isPending || reject.isPending || revise.isPending; // Verb order matches the collapsed row exactly (revise → reject → approve), // so expanding never moves the button the operator was already aiming at. return ( <>