Files
PaperClipAI/ui/src/components/AttentionQueueRow.tsx
T
DottaandClaude Opus 4.8 00a8b8f842 PAP-13409 T3: row/card redesign — type colors, detail, chips, thumbnails, snooze/undo
Replace the severity-based row accent with one canonical type→color map in
lib/attention.ts, sourced from IssueThreadInteractionCard's palette (sky
confirmations/questions/verdicts, violet plan approvals, rose failures, amber
blocked/recovery/budget, neutral join requests). Severity is demoted to a small
Critical/High badge only.

AttentionQueueRow:
- Whole collapsed header toggles expand (role=button, aria-expanded, Enter/Space);
  navigation only via the identifier link / Open button / links in the card.
- Collapsed inline rows surface decision-verb buttons that expand the card.
- Richer 2-line detail line from T1's structured detail block (questions,
  suggested tasks, failures, budget, plan/confirmation excerpts).
- Project/workspace chips with click-to-filter into the toolbar filter state.
- Image thumbnail stack (≤3 + "+n") when detail.images is non-empty.
- Snooze presets (1h / 4h / tomorrow morning / next week) + custom date-time;
  Dismiss shows an ~8s undo toast that restores the row in place.

Extends ToastAction with an onClick (undo) variant. Adds component tests for the
color map, detail line and fold-click behavior, plus a Storybook showcase story.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:08:18 +00:00

560 lines
20 KiB
TypeScript

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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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 (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border border-border bg-card",
"transition-colors hover:border-border/80",
isHidden && "bg-muted/30 opacity-80 hover:opacity-100",
)}
data-attention-source={item.sourceKind}
data-attention-severity={item.severity}
>
{/* Type accent bar (canonical color map — never severity). */}
<span className={cn("absolute inset-y-0 left-0 w-1", tone.accent)} aria-hidden />
<div className="flex items-start gap-3 py-3 pl-4 pr-3">
{/* Clickable header region: toggles expand for inline rows (plan §2/§5). */}
<div
className={cn(
"flex min-w-0 flex-1 items-start gap-3 rounded-md",
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-[3px] focus-visible:outline-none",
)}
{...(expandable
? {
role: "button",
tabIndex: 0,
"aria-expanded": expanded,
"aria-label": expanded ? "Collapse decision" : "Expand decision",
onClick: activate,
onKeyDown: onHeaderKeyDown,
}
: {})}
>
{/* Expand affordance / source icon */}
{expandable ? (
<span className="mt-0.5 shrink-0 p-0.5 text-muted-foreground" aria-hidden>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
) : (
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
<Icon className={cn("h-4 w-4", tone.icon)} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
{meta.label}
</span>
{sevBadge && (
<span
className={cn(
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
sevBadge.className,
)}
>
{sevBadge.label}
</span>
)}
{item.relatedIssue?.identifier && (
<Link
to={item.relatedIssue.href ?? "#"}
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{item.relatedIssue.identifier}
</Link>
)}
{isHidden && snoozedUntil ? (
<span
className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground"
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
>
<AlarmClock className="h-3 w-3" />
Reappears {reappearLabel(snoozedUntil)}
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground">
<Clock className="h-3 w-3" />
{relativeTime(item.activityAt)}
</span>
)}
</div>
<div className="mt-1 flex items-start gap-3">
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
{item.subject.title ?? meta.label}
</span>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
{(item.project || workspaceLabel) && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{item.project && (
<Chip
icon={Folder}
label={item.project.name}
onClick={
onFilterProject
? (e) => {
e.stopPropagation();
onFilterProject(item.project!);
}
: undefined
}
/>
)}
{workspaceLabel && <Chip icon={Boxes} label={workspaceLabel} />}
</div>
)}
</div>
{images.length > 0 && <ThumbnailStack images={images} />}
</div>
{/* Inline decision verbs on collapsed inline rows (plan §3). */}
{expandable && !expanded && verbs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{verbs.map((verb) => (
<Button
key={verb.id}
type="button"
variant="outline"
size="xs"
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
>
{verb.label}
</Button>
))}
</div>
)}
</div>
</div>
{/* Controls: kept as siblings (not inside the clickable header) so they
never toggle expand and stay valid interactive targets. */}
<div className="flex shrink-0 items-start gap-1">
{!inline && href && (
<Button asChild variant="outline" size="xs">
<Link to={href}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
)}
{isHidden ? (
onRestore && (
<Button type="button" variant="outline" size="xs" onClick={() => onRestore(item)}>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
{href && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{inline && expanded && (
<div className="border-t border-border/60 bg-muted/20 px-4 py-3">
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
</div>
)}
</div>
);
}
/** 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 = (
<>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</>
);
if (onClick) {
return (
<button type="button" className={className} onClick={onClick} title={`Filter by ${label}`}>
{content}
</button>
);
}
return <span className={className}>{content}</span>;
}
/** 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 (
<div className="flex shrink-0 items-center">
<div className="flex -space-x-3">
{visible.map((img, i) => (
<img
key={img.assetId}
src={attentionImageUrl(img.assetId)}
alt={img.alt ?? ""}
loading="lazy"
style={{ zIndex: visible.length - i }}
className="h-11 w-11 rounded-md border border-border bg-muted object-cover shadow-sm"
/>
))}
</div>
{extra > 0 && (
<span className="ml-1 inline-flex h-6 items-center rounded-md border border-border bg-muted px-1.5 text-(length:--text-nano) font-medium text-muted-foreground">
+{extra}
</span>
)}
</div>
);
}
/** 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 (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<AlarmClock className="h-4 w-4" />
Snooze
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SNOOZE_PRESETS.map((preset) => (
<DropdownMenuItem key={preset.label} onClick={() => onSnooze(preset.resolve())}>
{preset.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* Custom picker: a non-menu-item region so interacting with the input
doesn't close the menu (guard keydown/select against Radix typeahead). */}
<div
className="flex flex-col gap-1.5 px-2 py-1.5"
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<span className="text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Custom
</span>
<input
type="datetime-local"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
/>
<Button type="button" size="xs" disabled={!customValue} onClick={applyCustom}>
Snooze until…
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/** 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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
}) {
if (item.sourceKind === "issue_thread_interaction") {
const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
if (!issueId) {
return <p className="text-xs text-muted-foreground">Missing issue reference for this decision.</p>;
}
return (
<AttentionInteractionResolver
companyId={companyId}
issueId={issueId}
interactionId={item.subject.id}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
);
}
if (item.sourceKind === "approval") {
return <ApprovalResolver item={item} companyId={companyId} />;
}
if (item.sourceKind === "join_request") {
return <JoinRequestResolver item={item} companyId={companyId} />;
}
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 (
<div className="space-y-3">
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional decision note…"
className="min-h-[64px] text-sm"
/>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
<Button size="sm" variant="outline" onClick={() => revise.mutate()} disabled={pending}>
{revise.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Request revision
</Button>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
</div>
</div>
);
}
function JoinRequestResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
};
const approve = useMutation({
mutationFn: () => accessApi.approveJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
});
const reject = useMutation({
mutationFn: () => accessApi.rejectJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
});
const pending = approve.isPending || reject.isPending;
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
</div>
);
}