feat(decisions): expand image rows into gallery (#9532)

## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and review work that needs human attention.
> - The Decisions page condenses approvals, failed runs, reviews, and
issue interactions into a scannable attention queue.
> - Some decision rows already include screenshot evidence, but the
collapsed thumbnail stack is too small for meaningful inspection.
> - Image-only rows were not expandable because expansion was previously
reserved for inline decision resolvers.
> - Reviewers therefore had to leave the Decisions page before they
could understand the visual evidence attached to a row.
> - This pull request makes rows with images expandable and presents a
readable, linked gallery while preserving the compact collapsed view.
> - The benefit is faster evidence review without sacrificing queue
density or existing inline-resolution behavior.

## Linked Issues or Issue Description

No public GitHub issue currently tracks this feature.

### Subsystem affected

`ui/` — React + Vite board UI

### Problem or motivation

Screenshot evidence in Decisions rows is only visible as small
overlapping thumbnails, and non-inline rows cannot expand to show it.
This makes visual review unnecessarily slow and forces reviewers to
navigate away from the queue.

### Proposed solution

Treat active rows with images as expandable, render the first three
images at a readable size in an expanded gallery, and link images plus
any remaining-image affordance to the related issue.

### Alternatives considered

Always rendering large images would make the queue difficult to scan;
opening the issue immediately preserves density but prevents in-context
review. An explicit expandable gallery keeps both behaviors available.

### Roadmap alignment

`ROADMAP.md` does not list overlapping Decisions image-gallery work.
This is a focused improvement to the existing review surface rather than
a new product area.

### Additional context

The Storybook variants document both the collapsed thumbnail treatment
and deterministic expanded gallery state for reviewer inspection.

## What Changed

- Allow active Decisions rows with screenshot evidence to expand even
when they have no inline resolver.
- Keep compact thumbnails in collapsed rows and render up to three
larger, linked images when expanded.
- Add an accessible remaining-image tile that links to the related issue
when more screenshots exist.
- Add component coverage for image-only expansion and the
remaining-image issue link.
- Add collapsed and expanded image-gallery Storybook variants, including
deterministic initial expansion.

## Verification

- `pnpm exec vitest run ui/src/components/AttentionQueueRow.test.tsx`
- `pnpm check:token-gates`
- `pnpm --dir ui typecheck`
- `pnpm --dir ui build-storybook`
- QA visual verification (light + dark, no defects):
https://github.com/paperclipai/paperclip/pull/9532#issuecomment-4964769485

## Risks

- Low risk: the change is isolated to Decisions row rendering and
Storybook fixtures.
- Rows with images gain a new expansion interaction, but existing inline
resolver behavior and deep links remain intact.
- The gallery intentionally limits the in-row preview to three images to
avoid unbounded row height.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 4.8 assisted with the original implementation
and tests.
- OpenAI `gpt-5.4` via Codex CLI assisted with current-master rebase
integration, verification, and PR preparation. The runtime
context-window size is not exposed; capabilities used include reasoning,
repository tool use, code execution, and test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-15 20:08:21 -05:00 committed by GitHub
parent d32ed88443
commit f1508a7929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 250 additions and 18 deletions

View File

@ -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(
<AttentionQueueRow
item={buildItem({
sourceKind: "review" as AttentionSourceKind,
inlineResolvable: false,
detail: {
kind: "generic",
summaryExcerpt: "3 files changed",
images: [
{ assetId: "img-1", alt: "one" },
{ assetId: "img-2", alt: "two" },
],
},
})}
companyId="c1"
expanded={false}
onToggleExpand={onToggleExpand}
onDismiss={noop}
/>,
);
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(
<AttentionQueueRow
item={buildItem({
sourceKind: "review" as AttentionSourceKind,
inlineResolvable: false,
relatedIssue: {
kind: "issue",
id: "issue-1",
companyId: "c1",
title: "Ship it",
identifier: "PAP-42",
status: "in_progress",
href: "/PAP/issues/PAP-42",
metadata: {},
},
detail: {
kind: "generic",
summaryExcerpt: "5 screenshots",
images: [
{ assetId: "img-1", alt: "one" },
{ assetId: "img-2", alt: "two" },
{ assetId: "img-3", alt: "three" },
{ assetId: "img-4", alt: "four" },
{ assetId: "img-5", alt: "five" },
],
},
})}
companyId="c1"
expanded
onToggleExpand={noop}
onDismiss={noop}
/>,
);
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(
<AttentionQueueRow
item={buildItem({
sourceKind: "review" as AttentionSourceKind,
inlineResolvable: false,
subject: {
kind: "issue",
id: "issue-1",
companyId: "c1",
title: "Unlinked review",
identifier: null,
status: "in_review",
href: null,
metadata: {},
},
detail: {
kind: "generic",
summaryExcerpt: "4 screenshots",
images: [
{ assetId: "img-1", alt: "one" },
{ assetId: "img-2", alt: "two" },
{ assetId: "img-3", alt: "three" },
{ assetId: "img-4", alt: "four" },
],
},
})}
companyId="c1"
expanded
onToggleExpand={noop}
onDismiss={noop}
/>,
);
const gallery = container?.querySelector('[data-attention-expanded-images="true"]');
expect(gallery?.textContent).toContain("1 more");
expect(gallery?.querySelectorAll("a")).toHaveLength(0);
});
});

View File

@ -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)) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{item.project && <ProjectMeta project={item.project} />}
{images.length > 0 && <ThumbnailStack images={images} />}
{hasImages && !expanded && <ThumbnailStack images={images} />}
</div>
)}
@ -330,15 +335,18 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
</div>
</div>
{inline && expanded && (
<div className="border-t border-border/60 bg-muted/20 px-4 py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200">
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
{expanded && (hasImages || inline) && (
<div className="space-y-3 border-t border-border/60 bg-muted/20 px-4 py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200">
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
{inline && (
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
)}
</div>
)}
</div>
@ -495,13 +503,13 @@ function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) {
return (
<div className="flex shrink-0 items-center">
<div className="flex -space-x-3">
{visible.map((img, i) => (
{visible.map((img, index) => (
<img
key={img.assetId}
key={`${img.assetId}-${index}`}
src={attentionImageUrl(img.assetId)}
alt={img.alt ?? ""}
loading="lazy"
style={{ zIndex: visible.length - i }}
style={{ zIndex: visible.length - index }}
className="h-11 w-11 rounded-md border border-border bg-muted object-cover shadow-sm"
/>
))}
@ -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 (
<div className="flex flex-wrap items-stretch gap-2" data-attention-expanded-images="true">
{visible.map((img, index) => {
const src = attentionImageUrl(img.assetId);
const key = `${img.assetId}-${index}`;
const image = (
<img
src={src}
alt={img.alt ?? ""}
loading="lazy"
className="h-32 w-44 rounded-md border border-border bg-muted object-cover shadow-sm"
/>
);
return issueHref ? (
<Link
key={key}
to={issueHref}
className="block rounded-md focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
onClick={(e) => e.stopPropagation()}
>
{image}
</Link>
) : (
<span key={key} className="block">
{image}
</span>
);
})}
{extra > 0 && (issueHref ? (
<Link
to={issueHref}
onClick={(e) => 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"
>
<span className="text-base font-semibold">{extra} more</span>
<span className="mt-0.5 inline-flex items-center gap-1 text-(length:--text-nano)">
View issue
<ExternalLink className="h-3 w-3" />
</span>
</Link>
) : (
<span className="flex h-32 w-24 items-center justify-center rounded-md border border-dashed border-border bg-muted/40 text-sm font-semibold text-muted-foreground">
{extra} more
</span>
))}
</div>
);
}
/** Snooze submenu: presets + a custom date-time (plan §6). */
function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) {
const [customValue, setCustomValue] = useState("");

View File

@ -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<string | null>(firstInline?.id ?? null);
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? firstInline?.id ?? null);
const [cleared, setCleared] = useState<Set<string>>(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();