[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

+2 -14
View File
@@ -1,5 +1,5 @@
import type { IssueAttachment } from "@paperclipai/shared";
import { isVideoContentType } from "./issue-output";
import { isVideoLikeOutput } from "./issue-output";
const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
"application/octet-stream",
@@ -36,19 +36,7 @@ export function isImageAttachment(attachment: Pick<IssueAttachment, "contentType
export function isVideoAttachment(
attachment: Pick<IssueAttachment, "contentType" | "originalFilename">,
) {
const contentType = normalizedContentType(attachment);
if (isVideoContentType(contentType)) return true;
if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(contentType)) return false;
const filename = (attachment.originalFilename ?? "").toLowerCase();
return (
filename.endsWith(".mp4") ||
filename.endsWith(".m4v") ||
filename.endsWith(".webm") ||
filename.endsWith(".mov") ||
filename.endsWith(".qt") ||
filename.endsWith(".quicktime")
);
return isVideoLikeOutput(attachment.contentType, attachment.originalFilename);
}
export function isMarkdownAttachment(
+21
View File
@@ -64,6 +64,15 @@ const GENERIC_BINARY_CONTENT_TYPES = new Set([
"application/x-binary",
]);
const VIDEO_FILENAME_EXTENSIONS = [
".mp4",
".m4v",
".webm",
".mov",
".qt",
".quicktime",
];
const BINARY_OUTPUT_APPLICATION_TYPES = new Set([
"application/wasm",
]);
@@ -201,6 +210,18 @@ export function isVideoContentType(contentType: string | null | undefined): bool
return normalizeOutputContentType(contentType).startsWith("video/");
}
export function isVideoLikeOutput(
contentType: string | null | undefined,
originalFilename?: string | null | undefined,
): boolean {
const type = normalizeOutputContentType(contentType);
if (type.startsWith("video/")) return true;
if (!GENERIC_BINARY_CONTENT_TYPES.has(type)) return false;
const filename = (originalFilename ?? "").trim().toLowerCase();
return VIDEO_FILENAME_EXTENSIONS.some((extension) => filename.endsWith(extension));
}
export function isImageContentType(contentType: string | null | undefined): boolean {
return normalizeOutputContentType(contentType).startsWith("image/");
}