Add shareable image galleries to task documents (#18157)

<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/247/changes/paperclip/247
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task documents hold plans, reports, and image evidence.
> - Task attachments already have an image gallery.
> - Inline document images had no gallery navigation or image links.
> - This change gives each document a gallery that uses the existing
modal.
> - People can review the image sequence and share a link to one image.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Image review in task documents, including the side panel and Classic
interface.

**Current behavior**

Inline document images do not open a gallery. People cannot move through
them with arrow keys or share a link to one image.

**Proposed behavior**

Select an image to open the gallery. Use arrow keys or Previous and
Next. The URL records its one-based position, for example
`#document-report&image=2`. Reloading that URL opens the selected image.
Revision links keep their exact saved content.

**Reason and benefit**

People can inspect a document's image evidence in reading order and
share the exact view.

No matching issue was found. The latest 300 open PR titles were checked
for related document gallery work.

## What Changed

- Add a document renderer that collects rendered images in reading
order, including reference-style and repeated images.
- Reuse the gallery modal with optional index-change and focus-return
controls.
- Keep the document owner in the saved tab identity when people switch
between local and other-task documents.
- Match explicit current-task document owners with local document tabs.
Keep review and viewer parameters during gallery navigation.
- Treat another Classic review receipt as a new request while image
navigation retains the selected revision.
- Make inline image controls work with a keyboard. Keep focus in the
modal and restore focus on close.
- Keep document, revision, review, and maximized-view link parameters
during image navigation.
- Fix a Classic revision-cache race found by the browser test. Recheck a
pinned revision when the saved document head changes.
- Update the feature and agent access maps. No new server API or
permission is needed.

## Verification

- Passed 119 focused tests for document galleries, the shared gallery,
document rendering, the revision panel, and hash routing.
- Passed all 3 Chromium journeys: desktop, mobile, and Classic. These
use real uploads and saved document revisions.
- The browser journeys also check explicit current-task owners, review
parameters after reopening, other-task links after tab switches, and a
second Classic review link after Return to latest.
- Browser checks cover keyboard and button navigation, wraparound,
shared-link reload, exact historical content, invalid positions, focus
return and containment, scroll locking, and Escape, Close, and backdrop
dismissal.
- UI typecheck, token gates, map checks, and diff whitespace checks
passed.
- The UI production build passed in the browser harness.
- Earlier browser runs found and then verified the Classic
revision-cache fix. A later build exited 137 while the full test suite
ran. The browser run passed after that suite stopped.
- Full repository typecheck and build could not complete because Cargo
is absent. The full repository test run did not complete within this
sandbox. These checks remain subject to CI.

## Risks

- Image positions follow document reading order. Editing an unpinned
document can change those positions. A revision link retains the saved
sequence.
- The shared modal now uses Radix Escape handling. The existing gallery
tests and nested mobile dialog journey pass.
- No schema change or migration.

## Model Used

OpenAI GPT-6 through Codex. The runtime did not expose a more specific
model ID or context window size. Used reasoning, code editing, shell
commands, and browser automation.

## 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:
DottaandPaperclip authored and GitHub committed 2026-10-30 07:52:15 -05:00
1 parent 2a221e8e80
commit cd5f930558
16 files changed
+489 -29

No files matched your search

@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { DocumentMarkdownBody } from "./DocumentMarkdownBody";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let container: HTMLDivElement;
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
const body = "![First](/first.png)\n\n![Second][shot]\n\n![Repeated](/first.png)\n\n[shot]: https://example.com/second.png";
function LocationProbe() {
const location = useLocation();
const navigate = useNavigate();
return <><output>{location.pathname}{location.search}{location.hash}</output><button onClick={() => navigate(-1)}>Back</button></>;
}
async function render(hash = "", markdown = body, revisionId?: string, documentIssueId?: string, currentIssueId?: string) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(<MemoryRouter initialEntries={[`/PAP/issues/example?keep=1${hash}`]}>
<QueryClientProvider client={client}><ThemeProvider>
<DocumentMarkdownBody documentKey="report" revisionId={revisionId} documentIssueId={documentIssueId} currentIssueId={currentIssueId}>{markdown}</DocumentMarkdownBody>
<LocationProbe />
</ThemeProvider></QueryClientProvider>
</MemoryRouter>);
});
}
async function click(selector: string) {
const element = document.querySelector<HTMLElement>(selector)!;
expect(element).not.toBeNull();
await act(async () => element.click());
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
}
async function key(key: string) {
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })));
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
}
function dialog() { return document.querySelector('[role="dialog"]'); }
describe("document image gallery", () => {
it("opens a clicked occurrence, navigates all rendered images, and updates the URL", async () => {
await render("#document-report&viewer=full&thread=thread-1");
await click('[aria-label="Open image: Repeated"]');
expect(dialog()?.textContent).toContain("3 / 3");
expect(dialog()?.querySelector("img")?.getAttribute("src")).toBe("/first.png");
expect(container.querySelector("output")?.textContent).toBe("/PAP/issues/example?keep=1#document-report&thread=thread-1&viewer=full&image=3");
await key("ArrowRight");
expect(dialog()?.textContent).toContain("1 / 3");
await click('button[title="Next"]');
expect(dialog()?.querySelector("img")?.getAttribute("src")).toBe("https://example.com/second.png");
expect(container.querySelector("output")?.textContent).toContain("image=2");
await key("ArrowLeft");
expect(dialog()?.textContent).toContain("1 / 3");
await click('button[title="Previous"]');
expect(dialog()?.textContent).toContain("3 / 3");
await key("Escape");
expect(dialog()).toBeNull();
expect(container.querySelector("output")?.textContent).not.toContain("image=");
expect(container.querySelector("output")?.textContent).toContain("viewer=full");
expect(document.activeElement?.getAttribute("aria-label")).toBe("Open image: Repeated");
});
it("loads a shared image URL for the exact revision and retains it on close", async () => {
await render("#document-report&revision=old&interaction=review&documentIssue=owner&image=2", body, "old", "owner");
expect(dialog()?.textContent).toContain("2 / 3");
expect(dialog()?.querySelector("a")?.getAttribute("href")).toBe("https://example.com/second.png");
await click('button[title="Close"]');
expect(container.querySelector("output")?.textContent).toContain("#document-report&revision=old&interaction=review&documentIssue=owner");
expect(dialog()).toBeNull();
});
it("opens a saved owner after the URL has changed to another document", async () => {
await render("#document-local&documentIssue=wrong-owner", body, "old", "owner");
await click('[aria-label="Open image: First"]');
expect(dialog()?.textContent).toContain("1 / 3");
expect(container.querySelector("output")?.textContent).toContain("#document-report&revision=old&documentIssue=owner&image=1");
await key("ArrowRight");
expect(container.querySelector("output")?.textContent).toContain("documentIssue=owner&image=2");
});
it("opens an explicit current-task link and keeps review parameters through navigation and reopening", async () => {
await render("#document-report&revision=old&interaction=review&viewer=full&documentIssue=current-task&image=2", body, "old", undefined, "current-task");
expect(dialog()?.textContent).toContain("2 / 3");
await key("ArrowRight");
expect(dialog()?.textContent).toContain("3 / 3");
await click('button[title="Close"]');
expect(dialog()).toBeNull();
await click('[aria-label="Open image: First"]');
expect(dialog()?.textContent).toContain("1 / 3");
const url = container.querySelector("output")?.textContent;
expect(url).toContain("revision=old");
expect(url).toContain("interaction=review");
expect(url).toContain("viewer=full");
expect(url).toContain("documentIssue=current-task");
});
it("does not match another task's saved tab to an implicit current-task link", async () => {
await render("#document-report&revision=old&image=2", body, "old", "other-task", "current-task");
expect(dialog()).toBeNull();
await click('[aria-label="Open image: First"]');
expect(dialog()?.textContent).toContain("1 / 3");
expect(container.querySelector("output")?.textContent).toContain("documentIssue=other-task");
});
it.each(["#document-other&image=1", "#document-report&image=100", "#document-report&image=0", "#document-report&revision=old&image=1"])("does not open an unavailable image target: %s", async (hash) => {
await render(hash);
expect(dialog()).toBeNull();
});
it("opens a single image from the keyboard without navigation controls", async () => {
await render("", "![Only](/only.png)");
const image = container.querySelector("img")!;
await act(async () => {
image.focus();
image.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
});
expect(dialog()?.textContent).toContain("1 / 1");
expect(dialog()?.querySelector('button[title="Next"]')).toBeNull();
await click('[role="dialog"]');
expect(dialog()).toBeNull();
expect(document.activeElement).toBe(image);
});
});
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { buildDocumentAnnotationHash, parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
import { ImageGalleryModal, type GalleryMediaItem } from "./ImageGalleryModal";
import { MarkdownBody } from "./MarkdownBody";
type DocumentMarkdownBodyProps = Omit<ComponentProps<typeof MarkdownBody>, "onImageClick"> & {
documentKey: string;
revisionId?: string;
documentIssueId?: string;
currentIssueId?: string;
};
export function DocumentMarkdownBody({ documentKey, revisionId, documentIssueId, currentIssueId, ...props }: DocumentMarkdownBodyProps) {
const location = useLocation();
const navigate = useNavigate();
const locationRef = useRef(location);
locationRef.current = location;
const containerRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLImageElement | null>(null);
const [items, setItems] = useState<GalleryMediaItem[]>([]);
const target = parseDocumentAnnotationHash(location.hash);
const matchesDocument = target?.documentKey === documentKey
&& target.revisionId === revisionId
&& (target.documentIssueId ?? currentIssueId) === (documentIssueId ?? currentIssueId);
const image = matchesDocument ? target.image : undefined;
const readImages = useCallback(() =>
Array.from(containerRef.current?.querySelectorAll<HTMLImageElement>(".paperclip-markdown img[src]") ?? [])
.filter((element) => Boolean(element.getAttribute("src"))), []);
useEffect(() => {
if (!image) return;
const images = readImages();
setItems(images.map((element, index) => ({
id: String(index),
contentPath: element.getAttribute("src")!,
downloadPath: element.getAttribute("src")!,
contentType: "image/*",
originalFilename: element.alt || `Image ${index + 1}`,
})));
if (!returnFocusRef.current?.isConnected) returnFocusRef.current = images[image - 1] ?? null;
}, [image, props.children, readImages]);
const updateImage = useCallback((index?: number) => {
const location = locationRef.current;
const current = parseDocumentAnnotationHash(location.hash);
const sameDocument = current?.documentKey === documentKey
&& current.revisionId === revisionId
&& (current.documentIssueId ?? currentIssueId) === (documentIssueId ?? currentIssueId);
const hash = buildDocumentAnnotationHash({
...(sameDocument ? current : { documentKey, threadId: null, commentId: null }),
revisionId,
documentIssueId: sameDocument ? current.documentIssueId : documentIssueId,
image: index === undefined ? undefined : index + 1,
});
navigate({ pathname: location.pathname, search: location.search, hash }, { replace: true, state: location.state });
}, [currentIssueId, documentIssueId, documentKey, navigate, revisionId]);
const openImage = useCallback((_src: string, element: HTMLImageElement) => {
const index = readImages().indexOf(element);
if (index < 0) return;
returnFocusRef.current = element;
updateImage(index);
}, [readImages, updateImage]);
return (
<div ref={containerRef}>
<MarkdownBody {...props} onImageClick={openImage} />
<ImageGalleryModal
items={items}
initialIndex={(image ?? 1) - 1}
open={Boolean(image && image <= items.length)}
onIndexChange={updateImage}
onOpenChange={(open) => { if (!open) updateImage(); }}
returnFocusRef={returnFocusRef}
/>
</div>
);
}
+1 -1
View File
@@ -126,7 +126,7 @@ describe("ImageGalleryModal", () => {
expect(document.body.textContent).toContain("1 / 2");
await act(async () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(onOpenChange).toHaveBeenCalledWith(false);
+24 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import { attachmentDownloadPath, attachmentFilename } from "@/lib/issue-attachments";
@@ -18,6 +18,8 @@ interface ImageGalleryModalProps {
initialIndex: number;
open: boolean;
onOpenChange: (open: boolean) => void;
onIndexChange?: (index: number) => void;
returnFocusRef?: RefObject<HTMLElement | null>;
}
export function ImageGalleryModal({
@@ -25,6 +27,8 @@ export function ImageGalleryModal({
initialIndex,
open,
onOpenChange,
onIndexChange,
returnFocusRef,
}: ImageGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const mediaRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
@@ -37,12 +41,16 @@ export function ImageGalleryModal({
}, [open, initialIndex]);
const goNext = useCallback(() => {
setCurrentIndex((i) => (i + 1) % items.length);
}, [items.length]);
const next = (currentIndex + 1) % items.length;
setCurrentIndex(next);
onIndexChange?.(next);
}, [currentIndex, items.length, onIndexChange]);
const goPrev = useCallback(() => {
setCurrentIndex((i) => (i - 1 + items.length) % items.length);
}, [items.length]);
const next = (currentIndex - 1 + items.length) % items.length;
setCurrentIndex(next);
onIndexChange?.(next);
}, [currentIndex, items.length, onIndexChange]);
useEffect(() => {
if (currentIndex < items.length) return;
@@ -50,15 +58,16 @@ export function ImageGalleryModal({
}, [currentIndex, items.length]);
useEffect(() => {
if (!open) return;
if (!open || items.length < 2) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
if (e.key === "ArrowRight") goNext();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "Escape") onOpenChange(false);
else goPrev();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, goNext, goPrev, onOpenChange]);
}, [open, goNext, goPrev, items.length]);
/** Close when clicking empty curtain space (not interactive elements or the image) */
const handleBackdropClick = useCallback(
@@ -90,7 +99,13 @@ export function ImageGalleryModal({
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex flex-col outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200"
onClick={handleBackdropClick}
aria-describedby={undefined}
onCloseAutoFocus={returnFocusRef ? (event) => {
event.preventDefault();
returnFocusRef.current?.focus({ preventScroll: true });
} : undefined}
>
<DialogPrimitive.Title className="sr-only">Image gallery</DialogPrimitive.Title>
{/* Top bar */}
<div className="flex items-center justify-between px-5 py-3 text-white/80 text-sm shrink-0">
<span className="truncate max-w-(--pct-50) font-medium" title={filename}>
@@ -63,6 +63,9 @@ vi.mock("../api/auth", () => ({
const mockLocation = vi.hoisted(() => ({ hash: "" }));
vi.mock("@/lib/router", () => ({ useLocation: () => mockLocation }));
vi.mock("./DocumentMarkdownBody", async () => ({
DocumentMarkdownBody: (await import("./MarkdownBody")).MarkdownBody,
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({
@@ -335,6 +338,13 @@ describe("IssueDocumentsSection", () => {
expect(latest).toBeTruthy();
await act(async () => latest!.click());
expect(container.textContent).toContain("Newer unreviewed text");
mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-2&image=2";
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
expect(container.textContent).toContain("Exact reviewed text");
expect(container.textContent).not.toContain("Newer unreviewed text");
mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-2&image=3";
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
expect(container.textContent).toContain("Exact reviewed text");
await act(async () => root.unmount());
scroll.mockRestore();
queryClient.clear();
+27 -12
View File
@@ -19,7 +19,7 @@ import { describeError } from "../api/errors";
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
import { copyTextToClipboard } from "../lib/clipboard";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import { parseDocumentAnnotationHash } from "../lib/document-annotation-hash";
import { buildDocumentAnnotationHash, parseDocumentAnnotationHash } from "../lib/document-annotation-hash";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import { useDraftOwner } from "../hooks/useDraftOwner";
@@ -28,6 +28,7 @@ import { FoldCurtain } from "./FoldCurtain";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { DocumentMarkdownBody } from "./DocumentMarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { Button } from "@/components/ui/button";
@@ -112,12 +113,19 @@ function renderFoldableBody(
body: string,
className?: string,
externalReferences?: MarkdownExternalReferenceMap,
galleryTarget?: { documentKey: string; revisionId?: string; currentIssueId?: string },
) {
return (
<FoldCurtain>
<MarkdownBody className={className} softBreaks={false} externalReferences={externalReferences}>
{body}
</MarkdownBody>
{galleryTarget ? (
<DocumentMarkdownBody {...galleryTarget} className={className} softBreaks={false} externalReferences={externalReferences}>
{body}
</DocumentMarkdownBody>
) : (
<MarkdownBody className={className} softBreaks={false} externalReferences={externalReferences}>
{body}
</MarkdownBody>
)}
</FoldCurtain>
);
}
@@ -355,8 +363,10 @@ function AccountIssueDocumentsSection({
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
const [handledRevisionHash, setHandledRevisionHash] = useState<string | null>(null);
const [handledRevisionHash, setHandledRevisionHash] = useState<{ target: string; latestRevisionId: string | null } | null>(null);
const documentHashTarget = useMemo(() => parseDocumentAnnotationHash(location.hash), [location.hash]);
const revisionTarget = documentHashTarget?.revisionId
? buildDocumentAnnotationHash({ ...documentHashTarget, image: undefined }) : null;
const revisionsQueryDocumentKey = revisionMenuOpenKey ?? (documentHashTarget?.revisionId ? documentHashTarget.documentKey : null);
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -896,9 +906,10 @@ function AccountIssueDocumentsSection({
}, [documentSubject.legacyPlanDocument, documentHashTarget, sortedDocuments]);
useEffect(() => {
if (!documentHashTarget?.revisionId || handledRevisionHash === location.hash) return;
if (!documentHashTarget?.revisionId) return;
const doc = sortedDocuments.find((entry) => entry.key === documentHashTarget.documentKey);
if (!doc) return;
if (handledRevisionHash?.target === revisionTarget && handledRevisionHash.latestRevisionId === doc.latestRevisionId) return;
if (revisionsQueryFailed) {
setError("Could not load the reviewed revision. Try opening the link again.");
return;
@@ -910,8 +921,8 @@ function AccountIssueDocumentsSection({
}
return;
}
if (previewRevision(doc, documentHashTarget.revisionId)) setHandledRevisionHash(location.hash);
}, [activeDocumentRevisions, documentHashTarget, getDocumentRevisions, handledRevisionHash, isFetchingDocumentRevisions, location.hash, previewRevision, revisionsQueryFailed, sortedDocuments]);
if (previewRevision(doc, documentHashTarget.revisionId)) setHandledRevisionHash({ target: revisionTarget!, latestRevisionId: doc.latestRevisionId });
}, [activeDocumentRevisions, documentHashTarget, getDocumentRevisions, handledRevisionHash, isFetchingDocumentRevisions, previewRevision, revisionTarget, revisionsQueryFailed, sortedDocuments]);
useEffect(() => {
return () => {
@@ -1112,7 +1123,7 @@ function AccountIssueDocumentsSection({
</Badge>
</div>
<div className={documentBodyPaddingClassName}>
{renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
{renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
</div>
</div>
) : null}
@@ -1136,7 +1147,11 @@ function AccountIssueDocumentsSection({
? selectedHistoricalRevision.title ?? ""
: activeDraft?.title ?? currentRevision.title ?? "";
const waitingForLinkedRevision = documentHashTarget?.documentKey === doc.key
&& Boolean(documentHashTarget.revisionId) && handledRevisionHash !== location.hash;
&& Boolean(documentHashTarget.revisionId)
&& (handledRevisionHash?.target !== revisionTarget || handledRevisionHash.latestRevisionId !== doc.latestRevisionId);
const galleryRevisionId = selectedHistoricalRevision?.id
?? (documentHashTarget?.documentKey === doc.key && documentHashTarget.revisionId === doc.latestRevisionId
? documentHashTarget.revisionId : undefined);
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body;
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber;
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt;
@@ -1411,7 +1426,7 @@ function AccountIssueDocumentsSection({
// Loading and error copy must never become editor input
// or annotation content. A blocked draft stays editable.
const renderedDocumentBody = waitingForLinkedRevision && !activeDraft ? null : isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences, { documentKey: doc.key, revisionId: galleryRevisionId, currentIssueId: issue?.id })
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
@@ -1433,7 +1448,7 @@ function AccountIssueDocumentsSection({
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences, { documentKey: doc.key, revisionId: galleryRevisionId, currentIssueId: issue?.id })
);
return documentSubject.annotations && (!waitingForLinkedRevision || activeDraft) ? (
+16 -3
View File
@@ -127,7 +127,7 @@ interface MarkdownBodyProps {
/** Resolve file links to app routes; null leaves the label without a link. */
resolveLinkHref?: (href: string) => string | null;
/** Called when a user clicks an inline image */
onImageClick?: (src: string) => void;
onImageClick?: (src: string, image: HTMLImageElement) => void;
/** Keep untrusted decision media inert: image references and diagram source only. */
mediaMode?: "render" | "reference";
/**
@@ -1045,8 +1045,21 @@ function MarkdownBodyImpl({
{...imgProps}
src={finalSrc}
alt={alt ?? ""}
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
role={onImageClick && finalSrc ? "button" : undefined}
tabIndex={onImageClick && finalSrc ? 0 : undefined}
aria-label={onImageClick && finalSrc ? `Open image: ${alt || "Untitled image"}` : undefined}
onClick={onImageClick && finalSrc ? (e) => {
e.preventDefault();
e.stopPropagation();
onImageClick(finalSrc, e.currentTarget);
} : undefined}
onKeyDown={onImageClick && finalSrc ? (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
onImageClick(finalSrc, e.currentTarget);
} : undefined}
className={cn(imgProps.className, onImageClick && "cursor-pointer")}
/>
);
};
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { TaskDocumentPanel } from "./TaskDocumentPanel";
import { MemoryRouter } from "react-router-dom";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mocks = vi.hoisted(() => ({ get: vi.fn(), revision: vi.fn() }));
@@ -15,16 +16,23 @@ vi.mock("@/components/IssueDocumentAnnotations", () => ({
IssueDocumentAnnotations: ({ children, historicalPreview }: { children: React.ReactNode; historicalPreview: boolean }) => <div data-historical={historicalPreview}>{children}</div>,
}));
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
vi.mock("@/components/DocumentMarkdownBody", () => ({ DocumentMarkdownBody: ({ children, documentIssueId }: { children: React.ReactNode; documentIssueId?: string }) => <div data-gallery-owner={documentIssueId}>{children}</div> }));
let root: Root | undefined;
let container: HTMLDivElement;
afterEach(async () => { if (root) await act(async () => root!.unmount()); container?.remove(); root = undefined; vi.clearAllMocks(); });
async function render(issueId = "task") {
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => { root!.render(<QueryClientProvider client={client}><TaskDocumentPanel issueId={issueId} documentKey="copy" revisionId="old" /></QueryClientProvider>); });
await act(async () => { root!.render(<MemoryRouter><QueryClientProvider client={client}><TaskDocumentPanel issueId={issueId} documentKey="copy" revisionId="old" documentIssueId={issueId === "task-b" ? issueId : undefined} /></QueryClientProvider></MemoryRouter>); });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
}
describe("exact reviewed revision panel", () => {
it("passes the saved tab owner when the URL names a local document", async () => {
mocks.get.mockResolvedValue({ id: "doc", key: "copy", body: "LATEST BODY", updatedAt: new Date() });
mocks.revision.mockResolvedValue({ id: "old", title: "Reviewed copy", body: "REVIEWED BODY", format: "markdown", revisionNumber: 2, createdAt: new Date() });
await render("task-b");
expect(container.querySelector("[data-gallery-owner]")?.getAttribute("data-gallery-owner")).toBe("task-b");
});
it("renders the pinned body read-only even when the current document has advanced", async () => {
mocks.get.mockResolvedValue({ id: "doc", key: "copy", title: "New copy", body: "LATEST BODY", latestRevisionId: "new", latestRevisionNumber: 4, updatedAt: new Date() });
mocks.revision.mockResolvedValue({ id: "old", key: "copy", title: "Reviewed copy", body: "REVIEWED BODY", format: "markdown", revisionNumber: 2, createdAt: new Date() });
@@ -5,7 +5,7 @@ import { FileQuestion, Loader2 } from "lucide-react";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
import { MarkdownBody } from "@/components/MarkdownBody";
import { DocumentMarkdownBody } from "@/components/DocumentMarkdownBody";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { queryKeys } from "@/lib/queryKeys";
import { documentDisplayTitle } from "@/lib/issue-artifacts";
@@ -16,11 +16,15 @@ export function TaskDocumentPanel({
documentKey,
initialDocument,
revisionId,
documentIssueId,
currentIssueId = issueId,
}: {
issueId: string;
documentKey: string;
initialDocument?: IssueDocument;
revisionId?: string;
documentIssueId?: string;
currentIssueId?: string;
}) {
const location = useLocation();
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
@@ -102,7 +106,10 @@ export function TaskDocumentPanel({
onPanelOpenChange={setAnnotationPanelOpen}
panelPlacement="popover"
>
<MarkdownBody>{document.body}</MarkdownBody>
<DocumentMarkdownBody documentKey={document.key} revisionId={revisionId}
documentIssueId={documentIssueId} currentIssueId={currentIssueId}>
{document.body}
</DocumentMarkdownBody>
</IssueDocumentAnnotations>
) : (
<p className="text-sm text-muted-foreground">Document is empty.</p>
@@ -106,7 +106,7 @@ vi.mock("@/components/WorkspaceFileBrowser", () => ({
vi.mock("./TaskAttachmentPanel", () => ({ TaskAttachmentPanel: ({ attachmentId }: { attachmentId: string }) => <div>Text file {attachmentId}</div> }));
vi.mock("./TaskDocumentPanel", () => ({
TaskDocumentPanel: ({ documentKey, issueId, revisionId, initialDocument }: { documentKey: string; issueId: string; revisionId?: string; initialDocument?: IssueDocument }) => <div data-document-owner={issueId} data-revision-id={revisionId} data-initial-document={initialDocument?.id}>{`Document ${documentKey}`}</div>,
TaskDocumentPanel: ({ documentKey, issueId, revisionId, documentIssueId, currentIssueId, initialDocument }: { documentKey: string; issueId: string; revisionId?: string; documentIssueId?: string; currentIssueId?: string; initialDocument?: IssueDocument }) => <div data-document-owner={issueId} data-gallery-owner={documentIssueId} data-current-task={currentIssueId} data-revision-id={revisionId} data-initial-document={initialDocument?.id}>{`Document ${documentKey}`}</div>,
}));
vi.mock("./TaskWorkspaceFilePanel", () => ({
@@ -295,10 +295,15 @@ describe("TaskSidePanel", () => {
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "plan" } }));
const document = container.querySelector("[data-document-owner]");
expect(document?.getAttribute("data-document-owner")).toBe("task-b");
expect(document?.getAttribute("data-gallery-owner")).toBe("task-b");
expect(document?.getAttribute("data-current-task")).toBe("task-1");
expect(document?.getAttribute("data-revision-id")).toBe("b-v1");
expect(document?.hasAttribute("data-initial-document")).toBe(false);
expect(container.textContent).not.toContain("Origin task document must not leak");
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("document:task-b:plan:b-v1");
routeFixture.location.hash = "#document-local";
await render(panel());
expect(container.querySelector("[data-gallery-owner]")?.getAttribute("data-gallery-owner")).toBe("task-b");
});
it("preserves explicitly saved Coordinator tabs when a decision link adds its document", async () => {
@@ -850,6 +850,8 @@ function TaskSidePanelForScope({
issueId={activeTab.payload.documentIssueId ?? issue.id}
documentKey={activeTab.payload.documentKey}
revisionId={activeTab.payload.revisionId}
documentIssueId={activeTab.payload.documentIssueId}
currentIssueId={issue.id}
initialDocument={!activeTab.payload.documentIssueId || activeTab.payload.documentIssueId === issue.id ? documentByKey.get(activeTab.payload.documentKey) : undefined}
/>
);