import { useState, type KeyboardEvent, type MouseEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AlarmClock, Boxes, ChevronDown, ChevronRight, Clock, ExternalLink, Folder, Loader2, MoreHorizontal, RotateCcw, X, } from "lucide-react"; import type { Agent, AttentionDetailImage, AttentionItem, AttentionProjectRef } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { accessApi } from "../api/access"; import { approvalsApi } from "../api/approvals"; import { queryKeys } from "../lib/queryKeys"; import { attentionDetailImages, attentionDetailLine, attentionImageUrl, attentionToneStyle, isInlineResolvable, severityBadge, sourceMeta, } from "../lib/attention"; import { cn, relativeTime } from "../lib/utils"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { AttentionInteractionResolver } from "./AttentionInteractionResolver"; const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; /** 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; onToggleExpand: () => void; onDismiss: (item: AttentionItem) => void; onSnooze?: (item: AttentionItem, snoozedUntil: string) => void; /** Restore a snoozed/dismissed row (curtain variant only). */ onRestore?: (item: AttentionItem) => void; /** Click-to-filter on the project chip (wires into the toolbar filter state). */ onFilterProject?: (project: AttentionProjectRef) => void; /** "active" renders the live queue row; "hidden" renders a curtain row. */ variant?: "active" | "hidden"; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; } export function AttentionQueueRow({ item, companyId, expanded, onToggleExpand, onDismiss, onSnooze, onRestore, onFilterProject, variant = "active", agentMap, currentUserId, userLabelMap, }: AttentionQueueRowProps) { const meta = sourceMeta(item.sourceKind); const tone = attentionToneStyle(item); const sevBadge = severityBadge(item.severity); const Icon = meta.icon; 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); // Only inline-resolvable active rows can expand; that's the only case where a // whole-header click has somewhere to go (plan §5). Non-inline rows keep the // explicit Open button and never toggle on a stray click. const expandable = inline; const verbs = item.decisionVerbs.slice(0, 3); const workspaceLabel = item.workspace && item.workspace.name !== item.project?.name ? item.workspace.name : null; const activate = () => { if (expandable) onToggleExpand(); }; const onHeaderKeyDown = (e: KeyboardEvent) => { if (!expandable) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggleExpand(); } }; return (
{/* Type accent bar (canonical color map — never severity). */}
{/* Clickable header region: toggles expand for inline rows (plan §2/§5). */}
{/* Expand affordance / source icon */} {expandable ? ( {expanded ? : } ) : ( )}
{meta.label} {sevBadge && ( {sevBadge.label} )} {item.relatedIssue?.identifier && ( e.stopPropagation()} > {item.relatedIssue.identifier} )} {isHidden && snoozedUntil ? ( Reappears {reappearLabel(snoozedUntil)} ) : ( {relativeTime(item.activityAt)} )}
{item.subject.title ?? meta.label}

{detailLine}

{(item.project || workspaceLabel) && (
{item.project && ( { e.stopPropagation(); onFilterProject(item.project!); } : undefined } /> )} {workspaceLabel && }
)}
{images.length > 0 && }
{/* Inline decision verbs on collapsed inline rows (plan §3). */} {expandable && !expanded && verbs.length > 0 && (
{verbs.map((verb) => ( ))}
)}
{/* Controls: kept as siblings (not inside the clickable header) so they never toggle expand and stay valid interactive targets. */}
{!inline && href && ( )} {isHidden ? ( onRestore && ( ) ) : ( {onSnooze && onSnooze(item, iso)} />} onDismiss(item)}> Dismiss {href && ( <> Open source )} )}
{inline && expanded && (
)}
); } /** Small pill used for the project / workspace chips. Clickable when `onClick`. */ function Chip({ icon: Icon, label, onClick, }: { icon: typeof Folder; label: string; onClick?: (e: MouseEvent) => void; }) { const className = cn( "inline-flex max-w-[12rem] items-center gap-1 rounded-sm border border-border/70 bg-muted/40 px-1.5 py-0.5 text-(length:--text-nano) text-muted-foreground", onClick && "transition-colors hover:border-border hover:bg-accent hover:text-foreground", ); const content = ( <> {label} ); if (onClick) { return ( ); } return {content}; } /** 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, i) => ( {img.alt ))}
{extra > 0 && ( +{extra} )}
); } /** 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" />
); } /** 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`; } function InlineResolver({ item, companyId, agentMap, currentUserId, userLabelMap, }: { item: AttentionItem; companyId: string; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; }) { 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 ( ); } if (item.sourceKind === "approval") { return ; } if (item.sourceKind === "join_request") { return ; } return null; } function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) { 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; return (