[codex] Unify issue media attachment gallery (#8785)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue detail pages and run output cards both render user-facing
attachment previews
> - Media attachments were handled through separate gallery entry
points, so attachments from issue comments and issue output did not
share one consistent viewing path
> - A shared gallery path makes image browsing more predictable across
the issue surface
> - This pull request unifies the issue media attachment gallery
behavior across attachments, output cards, and issue detail rendering
> - The benefit is a more consistent image preview experience with
focused regression coverage around the affected UI paths

## Linked Issues or Issue Description

Refs #8788

This PR fixes inconsistent issue media preview behavior across
attachment and output surfaces.

## What Changed

- Unified issue media attachment gallery behavior across issue
attachments, issue output, output primary cards, and issue detail
rendering.
- Added image gallery modal coverage and regression tests for
attachments, output image handling, keyboard navigation, and issue
detail interactions.
- Preserved the primary video output Open action while still allowing
gallery browsing when a gallery handler is available.
- Updated issue attachment and issue output helper logic to preserve
shared image gallery metadata.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/ImageGalleryModal.test.tsx
src/components/issue-output/IssueOutputSection.test.tsx
src/components/IssueAttachmentsSection.test.tsx
src/pages/IssueDetail.test.tsx`
- Result: 4 files passed, 47 tests passed.

## Risks

Low to medium risk. The change is UI-scoped but touches shared issue
attachment/output rendering paths, so regressions would most likely
appear as image/video preview ordering, missing gallery entries, or
incorrect non-image attachment behavior.

> 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

OpenAI Codex, GPT-5 coding agent session with shell and git/GitHub CLI
tool use. Exact serving revision and context window were not exposed by
the runtime.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta authored and GitHub committed 2026-06-30 07:53:40 -07:00
1 parent 3e31bf09bc
commit b4fccaa8c4
12 files changed
+557 -80

No files matched your search

+3 -2
View File
@@ -291,7 +291,7 @@ vi.mock("../components/IssueWorkspaceCard", () => ({
}));
vi.mock("../components/ImageGalleryModal", () => ({
ImageGalleryModal: (props: { images: IssueAttachment[]; initialIndex: number; open: boolean }) => {
ImageGalleryModal: (props: { items: IssueAttachment[]; initialIndex: number; open: boolean }) => {
mockImageGalleryRender(props);
return null;
},
@@ -1998,7 +1998,8 @@ describe("IssueDetail", () => {
expect(container.textContent).toContain("report.md");
expect(container.textContent).toContain("Attachments1");
expect(container.querySelectorAll("video")).toHaveLength(1);
expect(mockImageGalleryRender.mock.calls.at(-1)?.[0].images.map((attachment: IssueAttachment) => attachment.id)).toEqual([
expect(mockImageGalleryRender.mock.calls.at(-1)?.[0].items.map((attachment: IssueAttachment) => attachment.id)).toEqual([
videoAttachment.id,
imageAttachment.id,
]);
});
+66 -10
View File
@@ -74,8 +74,13 @@ import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
import { isImageAttachment } from "../lib/issue-attachments";
import { getPromotedOutputAttachmentIds } from "../lib/issue-output";
import { isImageAttachment, isVideoAttachment } from "../lib/issue-attachments";
import {
getIssueOutputs,
getPromotedOutputAttachmentIds,
isImageContentType,
isVideoLikeOutput,
} from "../lib/issue-output";
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
import { IssuesList } from "../components/IssuesList";
@@ -91,7 +96,7 @@ import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ImageGalleryModal, type GalleryMediaItem } from "../components/ImageGalleryModal";
import { FileViewerProvider, useRequiredFileViewer } from "../context/FileViewerContext";
import { FileViewerSheet } from "../components/FileViewerSheet";
import { ArtifactFileChip } from "../components/ArtifactFileChip";
@@ -3114,17 +3119,55 @@ export function IssueDetail() {
() => (attachments ?? []).filter((attachment) => !promotedOutputAttachmentIds.has(attachment.id)),
[attachments, promotedOutputAttachmentIds],
);
const imageAttachments = useMemo(() => (attachments ?? []).filter(isImageAttachment), [attachments]);
const mediaGalleryItems = useMemo<GalleryMediaItem[]>(() => {
const items: GalleryMediaItem[] = [];
const seen = new Set<string>();
const mark = (attachmentId: string | null | undefined, contentPath: string) => {
if (attachmentId) seen.add(`attachment:${attachmentId}`);
seen.add(`content:${contentPath}`);
};
const hasSeen = (attachmentId: string | null | undefined, contentPath: string) => (
Boolean(attachmentId && seen.has(`attachment:${attachmentId}`)) ||
seen.has(`content:${contentPath}`)
);
for (const attachment of attachments ?? []) {
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) continue;
items.push(attachment);
mark(attachment.id, attachment.contentPath);
}
for (const item of getIssueOutputs(workProducts).items) {
const meta = item.metadata;
if (!meta) continue;
const isMedia = isImageContentType(meta.contentType) ||
isVideoLikeOutput(meta.contentType, meta.originalFilename);
if (!isMedia || hasSeen(meta.attachmentId, meta.contentPath)) continue;
items.push({
id: `work-product-${item.id}`,
contentPath: meta.contentPath,
openPath: meta.openPath,
downloadPath: meta.downloadPath,
contentType: meta.contentType,
originalFilename: meta.originalFilename ?? item.title,
});
mark(meta.attachmentId, meta.contentPath);
}
return items;
}, [attachments, workProducts]);
const handleChatImageClick = useCallback(
(src: string) => {
// Try exact contentPath match first
let idx = imageAttachments.findIndex((a) => a.contentPath === src);
let idx = mediaGalleryItems.findIndex((a) => a.contentPath === src);
if (idx < 0) {
// Try matching by asset ID extracted from /api/assets/{assetId}/content URLs
const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/);
if (assetMatch) {
idx = imageAttachments.findIndex((a) => a.assetId === assetMatch[1]);
idx = mediaGalleryItems.findIndex((a) => "assetId" in a && a.assetId === assetMatch[1]);
}
}
if (idx >= 0) {
@@ -3135,7 +3178,7 @@ export function IssueDetail() {
window.open(src, "_blank");
}
},
[imageAttachments],
[mediaGalleryItems],
);
const copyIssueToClipboard = async () => {
@@ -4123,7 +4166,20 @@ export function IssueDetail() {
userProfileMap={userProfileMap}
/>
<IssueOutputSection workProducts={workProducts} />
<IssueOutputSection
workProducts={workProducts}
onMediaClick={(item) => {
const meta = item.metadata;
if (!meta) return;
const idx = mediaGalleryItems.findIndex((galleryItem) => (
galleryItem.contentPath === meta.contentPath ||
galleryItem.id === `work-product-${item.id}` ||
galleryItem.id === meta.attachmentId
));
setGalleryIndex(idx >= 0 ? idx : 0);
setGalleryOpen(true);
}}
/>
{attachmentsInitialLoading ? (
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
@@ -4136,7 +4192,7 @@ export function IssueDetail() {
deletePending={deleteAttachment.isPending}
onDelete={(attachmentId) => deleteAttachment.mutate(attachmentId)}
onImageClick={(attachment) => {
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
const idx = mediaGalleryItems.findIndex((a) => a.id === attachment.id);
setGalleryIndex(idx >= 0 ? idx : 0);
setGalleryOpen(true);
}}
@@ -4157,7 +4213,7 @@ export function IssueDetail() {
) : null}
<ImageGalleryModal
images={imageAttachments}
items={mediaGalleryItems}
initialIndex={galleryIndex}
open={galleryOpen}
onOpenChange={setGalleryOpen}