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:
parent
d32ed88443
commit
f1508a7929
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue