diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 313fb95121..bc848aa631 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -502,4 +502,117 @@ describe("AttentionQueueRow", () => { ); expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull(); }); + + it("makes a non-inline row with images expandable", () => { + const onToggleExpand = vi.fn(); + render( + , + ); + const header = container?.querySelector('[role="button"][aria-expanded]'); + expect(header).not.toBeNull(); + act(() => (header as HTMLElement).click()); + expect(onToggleExpand).toHaveBeenCalledTimes(1); + }); + + it("shows a larger gallery with an n-more link to the issue when expanded", () => { + render( + , + ); + const gallery = container?.querySelector('[data-attention-expanded-images="true"]'); + expect(gallery).not.toBeNull(); + // First three images render at the larger size. + expect(gallery?.querySelectorAll("img")).toHaveLength(3); + // "n more" link points at the related issue (5 images − 3 shown = 2 more). + const moreLink = Array.from(gallery?.querySelectorAll("a") ?? []).find((a) => + a.textContent?.includes("2 more"), + ); + expect(moreLink).toBeDefined(); + expect(moreLink?.getAttribute("href")).toBe("/PAP/issues/PAP-42"); + }); + + it("shows the remaining image count when no issue link is available", () => { + render( + , + ); + + const gallery = container?.querySelector('[data-attention-expanded-images="true"]'); + expect(gallery?.textContent).toContain("1 more"); + expect(gallery?.querySelectorAll("a")).toHaveLength(0); + }); }); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index 6773f58907..aa56953ddf 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -116,10 +116,15 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ 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 + 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). Either case gives a + // header/thumbnail click somewhere to go. Non-inline, image-less rows keep the // explicit Open button and never toggle on a stray click. - const expandable = inline; + const expandable = inline || (!isHidden && hasImages); const activate = () => { if (expandable) onToggleExpand(item); @@ -281,10 +286,10 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ {/* Context row: project identity and evidence thumbnails move below the text so they never squeeze the headline on mobile. */} - {(item.project || images.length > 0) && ( + {(item.project || (hasImages && !expanded)) && (
{item.project && } - {images.length > 0 && } + {hasImages && !expanded && }
)} @@ -330,15 +335,18 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ - {inline && expanded && ( -
- + {expanded && (hasImages || inline) && ( +
+ {hasImages && } + {inline && ( + + )}
)}
@@ -495,13 +503,13 @@ function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) { return (
- {visible.map((img, i) => ( + {visible.map((img, index) => ( {img.alt ))} @@ -515,6 +523,63 @@ function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) { ); } +/** + * 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(""); diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/what-needs-me.stories.tsx index 05bc618b14..81745ddbb0 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/what-needs-me.stories.tsx @@ -279,6 +279,38 @@ const SHOWCASE: AttentionItem[] = [ }, ]; +// Image variations (PAP-13544): a row with images can be expanded by clicking +// its thumbnails; when expanded it shows the first three larger plus an +// "n more" link to the issue. These rows exercise every image count boundary. +const IMAGE_ROWS: AttentionItem[] = [ + { + ...item("img-review", "review", "medium", "PR ready for review: attention feed endpoint", "In-review issue is waiting on a human reviewer.", { + inlineResolvable: false, + project: { id: "proj-beta", name: "Beta", urlKey: "beta", color: "#7c3aed", icon: "layers" }, + }), + activityAt: new Date(NOW - 30 * 60 * 1000).toISOString(), + detail: { kind: "generic", summaryExcerpt: "5 files changed · +212 / −41", images: [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[0]] }, + }, + { + ...item("img-questions", "issue_thread_interaction", "medium", "Answer 2 questions on rollout", "Questions need answers.", { + inlineResolvable: true, + subject: { kind: "interaction", id: "intx-img", 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", color: "#0f766e", icon: "rocket" }, + }), + 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], IMAGES[3]] }, + }, + { + ...item("img-failed", "failed_run", "high", "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]] }, + }, +]; + const SNOOZED: AttentionItem[] = [ { ...item("snz-1", "review", "medium", "Design review: settings redesign", "Snoozed until this afternoon."), @@ -314,6 +346,7 @@ function Queue({ snoozed = [], dismissed = [], openCurtains = false, + initialExpandedId, }: { items: AttentionItem[]; groupBy?: AttentionGroupBy; @@ -321,9 +354,11 @@ function Queue({ snoozed?: AttentionItem[]; dismissed?: AttentionItem[]; openCurtains?: boolean; + /** Pre-expand a specific row (e.g. to show the larger image gallery). */ + initialExpandedId?: string; }) { const firstInline = items.find((i) => i.inlineResolvable && (i.sourceKind === "approval" || i.sourceKind === "join_request")); - const [expandedId, setExpandedId] = useState(firstInline?.id ?? null); + const [expandedId, setExpandedId] = useState(initialExpandedId ?? firstInline?.id ?? null); const [cleared, setCleared] = useState>(new Set()); const visible = items.filter((i) => !cleared.has(i.id)); @@ -472,6 +507,25 @@ export const TypeColorsAndDetail: Story = { args: { items: SHOWCASE, groupBy: "type" }, }; +/** + * Collapsed image rows (PAP-13544). Each row shows up to three small + * thumbnails plus a "+n" chip; clicking the thumbnails expands the row. Covers + * a 5-image review row, a 3-image (no "+n") questions row, and a single-image + * failed-run row. + */ +export const ImageThumbnails: Story = { + args: { items: IMAGE_ROWS }, +}; + +/** + * An expanded image row (PAP-13544). The 5-image review row is pre-expanded so + * the first three screenshots render larger with a "2 more" tile that links to + * the issue. + */ +export const ImageGalleryExpanded: Story = { + args: { items: IMAGE_ROWS, initialExpandedId: "img-review" }, +}; + /** The ~8s undo toast shown after dismissing a row (plan §6). */ function DismissUndoDemo() { const { pushToast } = useToastActions();