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>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-07-10 20:08:18 +00:00
1 parent 600a7c54c4
commit 00a8b8f842
8 files changed
+820 -145

No files matched your search

@@ -148,4 +148,71 @@ describe("AttentionQueueRow", () => {
const trigger = container?.querySelector('[aria-label="Row actions"]');
expect(trigger).toBeTruthy();
});
it("toggles expand when the collapsed header of an inline row is clicked", () => {
const onToggleExpand = vi.fn();
render(
<AttentionQueueRow
item={buildItem()}
companyId="c1"
expanded={false}
onToggleExpand={onToggleExpand}
onDismiss={noop}
/>,
);
const header = container?.querySelector('[role="button"][aria-expanded]');
expect(header).toBeTruthy();
expect(header?.getAttribute("aria-expanded")).toBe("false");
act(() => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onToggleExpand).toHaveBeenCalledTimes(1);
});
it("does not navigate on title click — the title is plain text, not a link", () => {
render(
<AttentionQueueRow
item={buildItem()}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
/>,
);
const links = Array.from(container?.querySelectorAll("a") ?? []);
// No anchor should carry the subject title (only the identifier link, absent here).
expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false);
});
it("renders inline decision verbs on a collapsed inline row", () => {
const el = render(
<AttentionQueueRow
item={buildItem({
decisionVerbs: [
{ id: "approve", label: "Approve", description: null },
{ id: "reject", label: "Reject", description: null },
],
})}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
/>,
);
expect(el.textContent).toContain("Approve");
expect(el.textContent).toContain("Reject");
});
it("does not expose a toggle button for non-inline rows", () => {
render(
<AttentionQueueRow
item={buildItem({ sourceKind: "failed_run" as AttentionSourceKind, inlineResolvable: false })}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
/>,
);
expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull();
});
});
+315 -134
View File
@@ -1,22 +1,32 @@
import { useState } from "react";
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, AttentionItem } from "@paperclipai/shared";
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 { isInlineResolvable, severityStyle, sourceMeta } from "../lib/attention";
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";
@@ -32,12 +42,23 @@ import {
} 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; ms: number }> = [
{ label: "1 hour", ms: 60 * 60 * 1000 },
{ label: "3 hours", ms: 3 * 60 * 60 * 1000 },
{ label: "Tomorrow", ms: 24 * 60 * 60 * 1000 },
{ label: "Next week", ms: 7 * 24 * 60 * 60 * 1000 },
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 {
@@ -49,6 +70,8 @@ interface AttentionQueueRowProps {
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>;
@@ -64,18 +87,40 @@ export function AttentionQueueRow({
onDismiss,
onSnooze,
onRestore,
onFilterProject,
variant = "active",
agentMap,
currentUserId,
userLabelMap,
}: AttentionQueueRowProps) {
const meta = sourceMeta(item.sourceKind);
const severity = severityStyle(item.severity);
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
@@ -87,149 +132,181 @@ export function AttentionQueueRow({
data-attention-source={item.sourceKind}
data-attention-severity={item.severity}
>
{/* Severity accent bar */}
<span className={cn("absolute inset-y-0 left-0 w-1", severity.accent)} aria-hidden />
{/* 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">
{/* Expand affordance / source icon */}
{inline ? (
<button
type="button"
onClick={onToggleExpand}
className="mt-0.5 shrink-0 rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring focus-visible:ring-[3px]"
aria-expanded={expanded}
aria-label={expanded ? "Collapse decision" : "Expand decision"}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
<Icon className="h-4 w-4 text-muted-foreground" />
</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="h-3.5 w-3.5" />
{meta.label}
{/* 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>
{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>
) : (
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
<Icon className={cn("h-4 w-4", tone.icon)} />
</span>
)}
<div className="mt-1 flex items-start gap-2">
<div className="min-w-0 flex-1">
{href ? (
<Link
to={href}
className="block truncate text-sm font-medium text-foreground hover:underline"
title={item.subject.title ?? undefined}
<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,
)}
>
{item.subject.title ?? meta.label}
{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="block truncate text-sm font-medium text-foreground">
{item.subject.title ?? meta.label}
<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>
)}
<p className="mt-0.5 text-xs text-muted-foreground">{item.whyNow}</p>
</div>
{/* Deep-link / open for non-inline rows */}
{!inline && href && (
<Button asChild variant="outline" size="xs" className="shrink-0">
<Link to={href}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
)}
<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>
{isHidden ? (
/* Curtain rows: restore back into the live queue. */
onRestore && (
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0"
onClick={() => onRestore(item)}
>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
)
) : (
/* Active rows: snooze presets + dismiss. */
<DropdownMenu>
<DropdownMenuTrigger asChild>
{(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
variant="ghost"
size="icon-xs"
className="shrink-0 text-muted-foreground"
aria-label="Row actions"
key={verb.id}
type="button"
variant="outline"
size="xs"
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
>
<MoreHorizontal className="h-4 w-4" />
{verb.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<AlarmClock className="h-4 w-4" />
Snooze
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SNOOZE_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.label}
onClick={() => onSnooze(item, new Date(Date.now() + preset.ms).toISOString())}
>
{preset.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<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>
</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 && (
@@ -247,6 +324,110 @@ export function AttentionQueueRow({
);
}
/** 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();
+21 -9
View File
@@ -56,15 +56,27 @@ function AnimatedToast({
{toast.body}
</p>
)}
{toast.action && (
<Link
to={toast.action.href}
onClick={() => onDismiss(toast.id)}
className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
>
{toast.action.label}
</Link>
)}
{toast.action &&
(toast.action.onClick ? (
<button
type="button"
onClick={() => {
toast.action?.onClick?.();
onDismiss(toast.id);
}}
className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
>
{toast.action.label}
</button>
) : toast.action.href ? (
<Link
to={toast.action.href}
onClick={() => onDismiss(toast.id)}
className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
>
{toast.action.label}
</Link>
) : null)}
</div>
<button
type="button"
+4 -1
View File
@@ -13,7 +13,10 @@ export type ToastTone = "info" | "success" | "warn" | "error";
export interface ToastAction {
label: string;
href: string;
/** Navigate on click (mutually exclusive with `onClick`). */
href?: string;
/** Run a callback on click, e.g. an undo (mutually exclusive with `href`). */
onClick?: () => void;
}
export interface ToastInput {
+101
View File
@@ -3,6 +3,9 @@ import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@papercl
import {
attentionBadgeCount,
attentionDateBucket,
attentionDetailLine,
attentionTone,
attentionToneStyle,
buildAttentionFilterOptions,
countActiveAttentionFilters,
defaultAttentionFilterState,
@@ -10,6 +13,7 @@ import {
groupAttentionItems,
isInlineResolvable,
NO_GROUP_SENTINEL,
severityBadge,
severityStyle,
sortAttentionItems,
sourceMeta,
@@ -107,6 +111,103 @@ describe("sourceMeta + severityStyle", () => {
});
});
describe("attentionTone + attentionToneStyle (canonical color map §4)", () => {
it("colors plan approvals violet regardless of source kind", () => {
const fromApproval = buildItem({
sourceKind: "approval",
detail: { kind: "plan_approval", issueTitle: "I", planTitle: "P", summaryExcerpt: null, images: [] },
});
const fromInteraction = buildItem({
sourceKind: "issue_thread_interaction",
detail: { kind: "plan_approval", issueTitle: "I", planTitle: "P", summaryExcerpt: null, images: [] },
});
expect(attentionTone(fromApproval)).toBe("violet");
expect(attentionTone(fromInteraction)).toBe("violet");
expect(attentionToneStyle(fromApproval).accent).toContain("violet");
});
it("colors confirmations / questions / verdicts in the sky family", () => {
expect(attentionTone(buildItem({ sourceKind: "approval" }))).toBe("sky");
expect(attentionTone(buildItem({ sourceKind: "issue_thread_interaction" }))).toBe("sky");
expect(
attentionTone(
buildItem({
sourceKind: "issue_thread_interaction",
detail: { kind: "questions", questionCount: 2, firstQuestionText: "?", images: [] },
}),
),
).toBe("sky");
});
it("colors failures rose and blocked/recovery/budget amber", () => {
expect(attentionTone(buildItem({ sourceKind: "failed_run" }))).toBe("rose");
expect(attentionTone(buildItem({ sourceKind: "agent_error_alert" }))).toBe("rose");
expect(attentionTone(buildItem({ sourceKind: "blocker_attention" }))).toBe("amber");
expect(attentionTone(buildItem({ sourceKind: "recovery_action" }))).toBe("amber");
expect(attentionTone(buildItem({ sourceKind: "budget_alert" }))).toBe("amber");
});
it("colors join requests neutral", () => {
expect(attentionTone(buildItem({ sourceKind: "join_request" }))).toBe("neutral");
});
it("gives every tone a distinct accent and never keys color off severity", () => {
const rose = buildItem({ sourceKind: "failed_run", severity: "low" });
const amber = buildItem({ sourceKind: "budget_alert", severity: "critical" });
// Same-source rows with opposite severities share one accent (color ≠ severity).
expect(attentionToneStyle(buildItem({ sourceKind: "failed_run", severity: "critical" })).accent).toBe(
attentionToneStyle(rose).accent,
);
expect(attentionToneStyle(rose).accent).not.toBe(attentionToneStyle(amber).accent);
});
});
describe("severityBadge", () => {
it("only surfaces a badge for Critical/High", () => {
expect(severityBadge("critical")?.label).toBe("Critical");
expect(severityBadge("high")?.label).toBe("High");
expect(severityBadge("medium")).toBeNull();
expect(severityBadge("low")).toBeNull();
});
});
describe("attentionDetailLine (§7)", () => {
it("summarizes questions with a count and the first question", () => {
const line = attentionDetailLine(
buildItem({
detail: { kind: "questions", questionCount: 2, firstQuestionText: "Which auth provider?", images: [] },
}),
);
expect(line).toContain("2 questions");
expect(line).toContain("Which auth provider?");
});
it("singularizes a single suggested task", () => {
const line = attentionDetailLine(
buildItem({
detail: { kind: "suggested_tasks", taskCount: 1, firstTaskTitle: "Add index", images: [] },
}),
);
expect(line).toContain("1 suggested task");
expect(line).not.toContain("tasks");
});
it("renders a failed run as agent — reason", () => {
const line = attentionDetailLine(
buildItem({
sourceKind: "failed_run",
detail: { kind: "failed_run", agentName: "Deployer", failureReasonExcerpt: "exit code 1", images: [] },
}),
);
expect(line).toContain("Deployer");
expect(line).toContain("exit code 1");
});
it("returns null when there is no detail block", () => {
expect(attentionDetailLine(buildItem({ detail: null }))).toBeNull();
});
});
describe("sortAttentionItems", () => {
const older = buildItem({ id: "old", activityAt: "2026-07-01T00:00:00Z", rank: 5 });
const newer = buildItem({ id: "new", activityAt: "2026-07-09T00:00:00Z", rank: 9 });
+187
View File
@@ -12,8 +12,10 @@ import {
type LucideIcon,
} from "lucide-react";
import type {
AttentionDetailImage,
AttentionFeed,
AttentionItem,
AttentionItemDetail,
AttentionProjectRef,
AttentionSeverity,
AttentionSourceKind,
@@ -76,6 +78,191 @@ export function severityStyle(severity: AttentionSeverity): SeverityStyle {
return SEVERITY_STYLE[severity] ?? SEVERITY_STYLE.low;
}
// ---------------------------------------------------------------------------
// Canonical type → color map (PAP-13409 §4)
//
// The row color is driven by the *kind of decision*, never by severity — one
// map, sourced from `IssueThreadInteractionCard`'s palette so a plan approval or
// confirmation reads identically in the queue and on the issue thread:
// • confirmations / questions / suggested-tasks / verdicts / reviews → sky
// • plan approvals → violet
// • failures (failed run, agent error) → rose
// • blocked / recovery / budget → amber
// • join request → neutral
// Severity only ever surfaces as a small Critical/High badge (never the accent).
// ---------------------------------------------------------------------------
export type AttentionTone = "sky" | "violet" | "rose" | "amber" | "neutral";
export interface AttentionToneStyle {
/** Left accent bar background. */
accent: string;
/** Source-icon tint. */
icon: string;
/** Chip / badge border+bg+text (matches the interaction card badge palette). */
chip: string;
}
const TONE_STYLE: Record<AttentionTone, AttentionToneStyle> = {
sky: {
accent: "bg-sky-500",
icon: "text-sky-600 dark:text-sky-400",
chip: "border-sky-500/60 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100",
},
violet: {
accent: "bg-violet-500",
icon: "text-violet-600 dark:text-violet-400",
chip: "border-violet-500/60 bg-violet-500/10 text-violet-900 dark:bg-violet-500/15 dark:text-violet-100",
},
rose: {
accent: "bg-rose-500",
icon: "text-rose-600 dark:text-rose-400",
chip: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100",
},
amber: {
accent: "bg-amber-500",
icon: "text-amber-600 dark:text-amber-400",
chip: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
},
neutral: {
accent: "bg-muted-foreground/40",
icon: "text-muted-foreground",
chip: "border-border/70 bg-muted/50 text-muted-foreground",
},
};
/**
* Resolve the canonical tone for a row. A plan approval is violet regardless of
* which surface tagged it (approval flow *or* issue-thread confirmation), so we
* check the T1 detail discriminant first, then fall back to the source kind.
*/
export function attentionTone(item: AttentionItem): AttentionTone {
if (item.detail?.kind === "plan_approval") return "violet";
switch (item.sourceKind) {
case "failed_run":
case "agent_error_alert":
return "rose";
case "blocker_attention":
case "recovery_action":
case "budget_alert":
return "amber";
case "join_request":
return "neutral";
case "approval":
case "issue_thread_interaction":
case "review":
case "productivity_review":
default:
return "sky";
}
}
export function attentionToneStyle(item: AttentionItem): AttentionToneStyle {
return TONE_STYLE[attentionTone(item)];
}
/**
* Severity is demoted to a small badge — and only when it is genuinely
* escalated (Critical/High). Medium/Low return `null` so most rows carry no
* severity chrome at all.
*/
export function severityBadge(severity: AttentionSeverity): { label: string; className: string } | null {
if (severity === "critical") {
return { label: "Critical", className: "border-red-500/60 bg-red-500/10 text-red-700 dark:text-red-300" };
}
if (severity === "high") {
return { label: "High", className: "border-orange-500/60 bg-orange-500/10 text-orange-700 dark:text-orange-300" };
}
return null;
}
// ---------------------------------------------------------------------------
// Richer detail line (PAP-13409 §7) — render T1's structured `detail` block into
// a single secondary line under the title (the caller clamps it to 2 lines).
// ---------------------------------------------------------------------------
function quote(text: string | null | undefined): string | null {
const trimmed = text?.trim();
if (!trimmed) return null;
return `“${trimmed}”`;
}
function countNoun(count: number, singular: string): string {
return `${count} ${count === 1 ? singular : `${singular}s`}`;
}
/**
* A concise human-readable detail line for a row, e.g.
* "2 questions — “Which auth provider…”"
* "Deploy failed — “exit code 1 on migrate”".
* Returns `null` when the detail carries nothing beyond the title, so the row
* can fall back to `whyNow`.
*/
export function attentionDetailLine(item: AttentionItem): string | null {
const detail = item.detail;
if (!detail) return null;
switch (detail.kind) {
case "plan_approval":
return detail.planTitle?.trim() || quote(detail.summaryExcerpt);
case "approval":
return quote(detail.summaryExcerpt);
case "confirmation":
return quote(detail.promptExcerpt);
case "checkbox_confirmation": {
const q = quote(detail.promptExcerpt);
return q ? `${countNoun(detail.optionCount, "option")} — ${q}` : countNoun(detail.optionCount, "option");
}
case "questions": {
const q = quote(detail.firstQuestionText);
const label = countNoun(detail.questionCount, "question");
return q ? `${label} — ${q}` : label;
}
case "suggested_tasks": {
const q = quote(detail.firstTaskTitle);
const label = countNoun(detail.taskCount, "suggested task");
return q ? `${label} — ${q}` : label;
}
case "item_verdicts": {
const q = quote(detail.promptExcerpt);
const label = `${countNoun(detail.itemCount, "item")} to verdict`;
return q ? `${label} — ${q}` : label;
}
case "failed_run":
case "agent_error": {
const reason = quote(detail.failureReasonExcerpt);
if (detail.agentName && reason) return `${detail.agentName} — ${reason}`;
return detail.agentName ?? reason;
}
case "blocker": {
const b = detail.blockingIssue;
if (!b) return null;
const id = b.identifier ? `${b.identifier} ` : "";
return b.title ? `Blocked by ${id}${b.title}` : b.identifier ? `Blocked by ${b.identifier}` : null;
}
case "budget":
return `${Math.round(detail.observedPercent)}% of budget used ($${detail.amountObserved} / $${detail.amountLimit})`;
case "generic":
return quote(detail.summaryExcerpt);
default:
return null;
}
}
/** Screenshot / thumbnail images attached to the detail block, if any. */
export function attentionDetailImages(item: AttentionItem): AttentionDetailImage[] {
return (item.detail as AttentionItemDetail | null)?.images ?? [];
}
/**
* Content URL for an attention detail image asset. Already-absolute or data
* URLs pass through unchanged (server may hand back a CDN URL; stories use data
* URIs), otherwise we resolve the in-app asset content route.
*/
export function attentionImageUrl(assetId: string): string {
if (assetId.startsWith("data:") || assetId.startsWith("http")) return assetId;
return `/api/assets/${assetId}/content`;
}
/**
* Decisions-only badge count. Every feed row *is* a pending decision (the
* server drops anything without a decision verb into Activity, per the §0
+26 -1
View File
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import type { Agent, AttentionItem, AttentionProjectRef } from "@paperclipai/shared";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { useInboxDismissals } from "../hooks/useInboxBadge";
import { queryKeys } from "../lib/queryKeys";
import {
@@ -67,6 +68,7 @@ export function WhatNeedsMe() {
const [pendingRestore, setPendingRestore] = useState<Set<string>>(() => new Set());
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
const { pushToast } = useToastActions();
useEffect(() => {
setBreadcrumbs([{ label: "What needs me" }]);
@@ -183,10 +185,28 @@ export function WhatNeedsMe() {
});
};
const handleUndoDismiss = (item: AttentionItem) => {
setPendingHide((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
restore(item.dismissalKey);
};
const handleDismiss = (item: AttentionItem) => {
setPendingHide((prev) => new Set(prev).add(item.id));
dismiss(item.dismissalKey);
if (expandedId === item.id) setExpandedId(null);
// ~8s undo window; restores the row in place via T1's DELETE endpoint.
pushToast({
id: `attention-dismiss-${item.id}`,
dedupeKey: `attention-dismiss-${item.dismissalKey}`,
title: "Dismissed",
body: item.subject.title ?? undefined,
tone: "info",
ttlMs: 8000,
action: { label: "Undo", onClick: () => handleUndoDismiss(item) },
});
};
const handleSnooze = (item: AttentionItem, snoozedUntil: string) => {
setPendingHide((prev) => new Set(prev).add(item.id));
@@ -197,6 +217,10 @@ export function WhatNeedsMe() {
setPendingRestore((prev) => new Set(prev).add(item.id));
restore(item.dismissalKey);
};
const handleFilterProject = (project: AttentionProjectRef) => {
if (filters.projectIds.includes(project.id)) return;
updateFilters({ ...filters, projectIds: [...filters.projectIds, project.id] });
};
const activeFilterCount = countActiveAttentionFilters(filters);
@@ -344,6 +368,7 @@ export function WhatNeedsMe() {
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onFilterProject={handleFilterProject}
agentMap={agentMap}
currentUserId={currentUserId}
/>
@@ -79,6 +79,19 @@ function item(
};
}
/** A visible colored tile as a data URI so thumbnails render in static screenshots. */
function thumb(hex: string, label: string): string {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='88' height='88'><rect width='88' height='88' fill='${hex}'/><text x='44' y='50' font-family='sans-serif' font-size='13' fill='white' text-anchor='middle'>${label}</text></svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
const IMAGES = [
{ assetId: thumb("#0ea5e9", "1"), alt: "screenshot 1" },
{ assetId: thumb("#8b5cf6", "2"), alt: "screenshot 2" },
{ assetId: thumb("#f43f5e", "3"), alt: "screenshot 3" },
{ assetId: thumb("#f59e0b", "4"), alt: "screenshot 4" },
];
const POPULATED: AttentionItem[] = [
item(
"recov-1",
@@ -177,12 +190,93 @@ const PROJECTS: Record<string, AttentionItem["project"]> = {
"intx-1": { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
"review-1": { id: "proj-beta", name: "Beta", urlKey: "beta" },
};
const DETAILS: Record<string, AttentionItem["detail"]> = {
"recov-1": { kind: "generic", summaryExcerpt: "Agent has not produced output in 40 minutes.", images: [] },
"appr-1": { kind: "approval", approvalType: "hire_agent", summaryExcerpt: "Adds a Research Analyst to the Growth pod.", images: [] },
"intx-1": {
kind: "questions",
questionCount: 2,
firstQuestionText: "Which auth provider should we standardize on?",
images: [IMAGES[0], IMAGES[1]],
},
"review-1": { kind: "generic", summaryExcerpt: "3 files changed · +212 / −41", images: [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3]] },
"fail-1": { kind: "failed_run", agentName: "Deployer", failureReasonExcerpt: "exit code 1 running migrate", images: [] },
"budget-1": { kind: "budget", observedPercent: 85, amountObserved: 425, amountLimit: 500, images: [] },
};
const POPULATED_DATED: AttentionItem[] = POPULATED.map((it) => ({
...it,
activityAt: new Date(ACTIVITY_OFFSETS[it.id] ?? NOW).toISOString(),
project: PROJECTS[it.id] ?? null,
detail: DETAILS[it.id] ?? it.detail,
}));
// A dedicated set exercising the §4 color map (a plan approval = violet next to a
// sky confirmation), §7 detail lines, §8 project chips and §10 thumbnail stacks.
const SHOWCASE: AttentionItem[] = [
{
...item("plan-1", "issue_thread_interaction", "high", "Approve plan: Attention queue redesign", "A plan is awaiting your approval.", {
inlineResolvable: true,
subject: { kind: "interaction", id: "intx-plan", companyId, title: "Approve plan: Attention queue redesign", identifier: null, status: "pending", href: "/PAP/issues/PAP-1000#plan", metadata: { kind: "request_confirmation", issueId: "issue-1000" } },
decisionVerbs: [
{ id: "approve", label: "Approve plan", description: null },
{ id: "request_changes", label: "Request changes", description: null },
],
project: { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
}),
activityAt: new Date(NOW - 20 * 60 * 1000).toISOString(),
detail: { kind: "plan_approval", issueTitle: "Attention home", planTitle: "Row/card redesign — 8 sections", summaryExcerpt: null, images: [IMAGES[1]] },
},
{
...item("conf-1", "approval", "medium", "Confirm: publish release notes", "A confirmation is pending.", {
inlineResolvable: true,
subject: { kind: "approval", id: "appr-conf", companyId, title: "Confirm: publish release notes", identifier: null, status: "pending", href: "/PAP/approvals/appr-conf", metadata: {} },
relatedIssue: null,
decisionVerbs: [
{ id: "approve", label: "Approve", description: null },
{ id: "reject", label: "Reject", description: null },
],
project: { id: "proj-beta", name: "Beta", urlKey: "beta" },
}),
activityAt: new Date(NOW - 40 * 60 * 1000).toISOString(),
detail: { kind: "confirmation", promptExcerpt: "Ship v2026.707.0 changelog to the public page?", isPlanTarget: false, images: [] },
},
{
...item("qs-1", "issue_thread_interaction", "medium", "Answer 2 questions on rollout", "Questions need answers.", {
inlineResolvable: true,
subject: { kind: "interaction", id: "intx-qs", companyId, title: "Answer 2 questions on rollout", identifier: null, status: "pending", href: "/PAP/issues/PAP-1000#qs", metadata: { kind: "ask_user_questions", issueId: "issue-1000" } },
decisionVerbs: [{ id: "respond", label: "Answer", description: null }],
project: { id: "proj-alpha", name: "Alpha", urlKey: "alpha" },
}),
activityAt: new Date(NOW - 90 * 60 * 1000).toISOString(),
detail: { kind: "questions", questionCount: 2, firstQuestionText: "Which auth provider should we standardize on?", images: [IMAGES[0], IMAGES[2]] },
},
{
...item("fail-2", "failed_run", "critical", "Deploy pipeline failed after 3 retries", "Retries exhausted.", {
inlineResolvable: false,
relatedIssue: null,
}),
activityAt: new Date(NOW - 3 * HOUR).toISOString(),
detail: { kind: "failed_run", agentName: "Deployer", failureReasonExcerpt: "exit code 1 running migrate", images: [IMAGES[3]] },
},
{
...item("budg-2", "budget_alert", "low", "Company budget crossed 85%", "Budget threshold crossed.", {
inlineResolvable: false,
relatedIssue: null,
}),
activityAt: new Date(NOW - 5 * HOUR).toISOString(),
detail: { kind: "budget", observedPercent: 85, amountObserved: 425, amountLimit: 500, images: [] },
},
{
...item("join-2", "join_request", "medium", "alex@acme.dev wants to join", "Join request pending.", {
inlineResolvable: true,
subject: { kind: "join_request", id: "join-2", companyId, title: "alex@acme.dev wants to join", identifier: null, status: "pending_approval", href: "/PAP/settings/access", metadata: {} },
relatedIssue: null,
}),
activityAt: new Date(NOW - 6 * HOUR).toISOString(),
},
];
const SNOOZED: AttentionItem[] = [
{
...item("snz-1", "review", "medium", "Design review: settings redesign", "Snoozed until this afternoon."),
@@ -283,6 +377,7 @@ function Queue({
onToggleExpand={() => setExpandedId((p) => (p === it.id ? null : it.id))}
onDismiss={(d) => setCleared((prev) => new Set(prev).add(d.id))}
onSnooze={(d) => setCleared((prev) => new Set(prev).add(d.id))}
onFilterProject={() => {}}
/>
))}
</div>
@@ -367,6 +462,10 @@ export const WithCurtains: Story = {
args: { items: POPULATED_DATED.slice(0, 3), groupBy: "date", snoozed: SNOOZED, dismissed: DISMISSED, openCurtains: true },
};
export const TypeColorsAndDetail: Story = {
args: { items: SHOWCASE, groupBy: "type" },
};
export const ZeroState: Story = {
args: { items: [] },
};