diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 84197d608eb..44105b7e070 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + expect(el.textContent).toContain("Approve"); + expect(el.textContent).toContain("Reject"); + }); + + it("does not expose a toggle button for non-inline rows", () => { + render( + , + ); + expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull(); + }); }); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index eea18ec5f36..3aee44aaf98 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -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; @@ -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 (
- {/* Severity accent bar */} - + {/* Type accent bar (canonical color map — never severity). */} +
- {/* Expand affordance / source icon */} - {inline ? ( - - ) : ( - - - - )} - -
-
- - - {meta.label} + {/* Clickable header region: toggles expand for inline rows (plan §2/§5). */} +
+ {/* Expand affordance / source icon */} + {expandable ? ( + + {expanded ? : } - {item.relatedIssue?.identifier && ( - e.stopPropagation()} - > - {item.relatedIssue.identifier} - - )} - {isHidden && snoozedUntil ? ( - - - Reappears {reappearLabel(snoozedUntil)} - - ) : ( - - - {relativeTime(item.activityAt)} - - )} -
+ ) : ( + + + + )} -
-
- {href ? ( - +
+ + + {meta.label} + + {sevBadge && ( + - {item.subject.title ?? meta.label} + {sevBadge.label} + + )} + {item.relatedIssue?.identifier && ( + e.stopPropagation()} + > + {item.relatedIssue.identifier} + )} + {isHidden && snoozedUntil ? ( + + + Reappears {reappearLabel(snoozedUntil)} + ) : ( - - {item.subject.title ?? meta.label} + + + {relativeTime(item.activityAt)} )} -

{item.whyNow}

- {/* Deep-link / open for non-inline rows */} - {!inline && href && ( - - )} +
+
+ + {item.subject.title ?? meta.label} + +

{detailLine}

- {isHidden ? ( - /* Curtain rows: restore back into the live queue. */ - onRestore && ( - - ) - ) : ( - /* Active rows: snooze presets + dismiss. */ - - + {(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) => ( - - - {onSnooze && ( - - - - Snooze - - - {SNOOZE_PRESETS.map((preset) => ( - onSnooze(item, new Date(Date.now() + preset.ms).toISOString())} - > - {preset.label} - - ))} - - - )} - onDismiss(item)}> - - Dismiss - - {href && ( - <> - - - Open source - - - )} - - + ))} +
)}
+ + {/* 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 && ( @@ -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 = ( + <> + + {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(); diff --git a/ui/src/components/ToastViewport.tsx b/ui/src/components/ToastViewport.tsx index 3abdc20b13e..23a53bda246 100644 --- a/ui/src/components/ToastViewport.tsx +++ b/ui/src/components/ToastViewport.tsx @@ -56,15 +56,27 @@ function AnimatedToast({ {toast.body}

)} - {toast.action && ( - onDismiss(toast.id)} - className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90" - > - {toast.action.label} - - )} + {toast.action && + (toast.action.onClick ? ( + + ) : toast.action.href ? ( + onDismiss(toast.id)} + className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90" + > + {toast.action.label} + + ) : null)}
@@ -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: [] }, };