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

@@ -16,6 +16,11 @@ for format, permission checks, evidence and maintenance.
Capability ID: `documents-artifacts/documents`.
Document image selection is browser presentation state. It needs no new API or
agent permission. Agents can share `#document-<key>&image=<one-based-position>`
links with optional `revision` and `viewer=full` parameters. Existing task,
document, revision, and image resource read permissions still apply.
Coordinator receipt reads add `GET /api/issues/{id}/documents/{key}/revisions/{revisionId}`.
It applies the same task-read authorization as document revision history and
queries exactly one revision under that task/document key. It does not fall back
+12
View File
@@ -26,6 +26,13 @@ Agent access: [companion](../agent-access-map/documents-artifacts.md).
Open a document from the task thread or document side panel.
Select an inline image to open the document's image gallery. Use the left and
right arrow keys or the Previous and Next buttons to move through images in
reading order. The URL retains the document and adds a one-based `image` position,
such as `#document-report&image=2`. Share or reload that URL to open the same image.
Revision and maximized-view parameters remain in the link. Escape, Close, or the
backdrop closes the gallery and returns focus to the image.
Coordinator decision receipts also open a pinned reviewed revision alongside
the matching task interaction. Historical tabs retain their revision identity
independently of the current document tab. Missing revisions are explicit and
@@ -55,6 +62,11 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use
Automated: [documents service](../../server/src/__tests__/documents-service.test.ts), [annotations](../../server/src/__tests__/document-annotations-service.test.ts), and [revision restore](../../server/src/__tests__/issue-document-restore-routes.test.ts) cover server contracts.
[Document image galleries](../../tests/e2e/document-image-gallery.spec.ts) checks
desktop, mobile, and Classic document views with real uploaded images and saved
document revisions. It covers keyboard/button navigation, image URLs and reload,
focus return, focus containment, scroll locking, dismissal, and revision links.
[Exact revision panel](../../ui/src/components/task-side-panel/TaskDocumentPanel.test.tsx)
checks pinned content and missing-revision behavior; this component test does not
prove end-to-end conversation scrolling. Open a resolved receipt after making a
+143
View File
@@ -0,0 +1,143 @@
import { randomUUID } from "node:crypto";
import { expect, test, type APIRequestContext } from "@playwright/test";
async function json(response: Awaited<ReturnType<APIRequestContext["get"]>>) {
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
return response.json();
}
for (const mode of ["desktop", "mobile", "classic"] as const) {
test(`document images share a gallery and deep links (${mode})`, async ({ page, request }, testInfo) => {
const mobile = mode === "mobile";
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
const settings = await json(await request.get("/api/instance/settings/experimental"));
try {
await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: mode === "classic" } }));
const company = await json(await request.post("/api/companies", { data: { name: `Document gallery ${randomUUID()}` } }));
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
data: { title: "Review the design document", status: "backlog" },
}));
const paths: string[] = [];
for (const [index, title] of ["Plan the work", "Review the result", "Share the evidence"].entries()) {
const encoded = await page.evaluate(({ index, title }) => {
const canvas = document.createElement("canvas");
canvas.width = 1000; canvas.height = 600;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = ["#e0e7ff", "#ccfbf1", "#fef3c7"][index]!;
ctx.fillRect(0, 0, 1000, 600);
ctx.fillStyle = "#111827";
ctx.font = "bold 54px sans-serif";
ctx.fillText(title, 65, 125);
ctx.font = "24px sans-serif";
ctx.fillText(`Design review · Step ${index + 1}`, 65, 180);
for (let card = 0; card < 3; card++) {
ctx.fillStyle = "#ffffff"; ctx.fillRect(65 + card * 295, 255, 260, 220);
ctx.fillStyle = ["#4f46e5", "#0f766e", "#b45309"][index]!;
ctx.fillRect(90 + card * 295, 285, 65, 12);
ctx.fillStyle = "#d1d5db";
ctx.fillRect(90 + card * 295, 340, 190, 10);
ctx.fillRect(90 + card * 295, 370, 155, 10);
}
return canvas.toDataURL("image/png").split(",")[1]!;
}, { index, title });
const attachment = await json(await request.post(`/api/companies/${company.id}/issues/${issue.id}/attachments`, {
multipart: { file: { name: `step-${index + 1}.png`, mimeType: "image/png", buffer: Buffer.from(encoded, "base64") } },
}));
paths.push(attachment.contentPath);
}
const saved = await json(await request.put(`/api/issues/${issue.id}/documents/design`, {
data: { title: "Design review", format: "markdown", body: `# Design review\n\nSelect an image to review the sequence.\n\n![Plan the work](${paths[0]})\n\n![Review the result][review]\n\n![Share the evidence](${paths[2]})\n\n[review]: ${paths[1]}` },
}));
const taskPath = `/${company.issuePrefix}/issues/${issue.identifier}`;
await page.goto(`${taskPath}#document-design&viewer=full`);
const first = page.getByRole("button", { name: "Open image: Plan the work", exact: true }).first();
await expect(first).toBeVisible();
await first.click();
const gallery = page.getByRole("dialog", { name: "Image gallery", exact: true });
await expect(gallery).toBeVisible();
await expect(gallery).toContainText("1 / 3");
await expect(page).toHaveURL(/image=1/);
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
await gallery.getByRole("img").click();
await expect(gallery).toBeVisible();
await page.keyboard.press("ArrowRight");
await expect(gallery).toContainText("2 / 3");
await expect(gallery.getByRole("img")).toHaveAttribute("src", paths[1]!);
await expect(page).toHaveURL(/image=2/);
await page.screenshot({ path: testInfo.outputPath("document-gallery.png"), fullPage: true });
const sharedUrl = page.url();
await gallery.getByRole("button", { name: "Next", exact: true }).click();
await expect(gallery).toContainText("3 / 3");
await page.keyboard.press("ArrowRight");
await expect(gallery).toContainText("1 / 3");
await gallery.getByRole("button", { name: "Previous", exact: true }).click();
await expect(gallery).toContainText("3 / 3");
await page.keyboard.press("Escape");
await expect(gallery).toBeHidden();
await expect(first).toBeFocused();
await expect(page).not.toHaveURL(/image=/);
if (mobile) await expect(page.getByTestId("mobile-task-side-panel")).toBeVisible();
await page.goto(sharedUrl);
await expect(gallery).toContainText("2 / 3");
await page.reload();
await expect(gallery).toContainText("2 / 3");
await gallery.getByRole("button", { name: "Close", exact: true }).focus();
await page.keyboard.press("Tab");
await expect(gallery.getByRole("button", { name: "Previous", exact: true })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(gallery.getByRole("button", { name: "Close", exact: true })).toBeFocused();
await gallery.getByRole("button", { name: "Close", exact: true }).click();
await expect(gallery).toBeHidden();
const revised = await json(await request.put(`/api/issues/${issue.id}/documents/design`, {
data: { title: "Design review", body: "# New revision\n\nNo images.", format: "markdown", baseRevisionId: saved.latestRevisionId },
}));
expect(revised.latestRevisionId).not.toBe(saved.latestRevisionId);
await page.goto(`${taskPath}#document-design&viewer=full&revision=${saved.latestRevisionId}&interaction=current-task-review&documentIssue=${issue.id}&image=3`);
await expect(gallery).toContainText("3 / 3");
await expect(gallery.getByRole("img")).toHaveAttribute("src", paths[2]!);
await page.screenshot({ path: testInfo.outputPath("document-gallery-revision.png"), fullPage: true });
await gallery.click({ position: { x: 5, y: 180 } });
await expect(gallery).toBeHidden();
await expect(page).toHaveURL(new RegExp(`revision=${saved.latestRevisionId}`));
await first.click();
await expect(gallery).toContainText("1 / 3");
await page.keyboard.press("ArrowRight");
await expect(gallery).toContainText("2 / 3");
await expect(page).toHaveURL(/interaction=current-task-review/);
await expect(page).toHaveURL(/viewer=full/);
await expect(page).toHaveURL(new RegExp(`documentIssue=${issue.id}`));
await page.reload();
await expect(gallery).toContainText("2 / 3");
await gallery.getByRole("button", { name: "Close", exact: true }).click();
if (mode === "classic") {
await page.getByRole("button", { name: "Return to latest", exact: true }).click();
await expect(page.getByRole("heading", { name: "New revision", exact: true })).toBeVisible();
await page.goto(`${taskPath}#document-design&revision=${saved.latestRevisionId}&interaction=another-review&image=1`);
await expect(gallery).toContainText("1 / 3");
await gallery.getByRole("button", { name: "Close", exact: true }).click();
}
await page.goto(`${taskPath}#document-design&image=100`);
await page.reload();
await expect(page.getByRole("heading", { name: "New revision", exact: true })).toBeVisible();
await expect(gallery).toBeHidden();
if (mode === "desktop") {
const owner = await json(await request.post(`/api/companies/${company.id}/issues`, { data: { title: "Document owner", status: "backlog" } }));
const owned = await json(await request.put(`/api/issues/${owner.id}/documents/design`, { data: { title: "Owned review", body: saved.body, format: "markdown" } }));
await page.goto(`${taskPath}#document-design&viewer=full&revision=${owned.latestRevisionId}&documentIssue=${owner.id}&image=2`);
await expect(gallery).toContainText("2 / 3");
await gallery.getByRole("button", { name: "Close", exact: true }).click();
await json(await request.put(`/api/issues/${issue.id}/documents/local-images`, { data: { title: "Local images", body: saved.body, format: "markdown" } }));
await page.goto(`${taskPath}#document-local-images&viewer=full`);
await page.getByRole("button", { name: "Open image: Plan the work", exact: true }).click();
await gallery.getByRole("button", { name: "Close", exact: true }).click();
await page.locator(`[role="tab"][data-side-panel-tab-target="document:${owner.id}:design:${owned.latestRevisionId}"]`).click();
await page.getByRole("button", { name: "Open image: Plan the work", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`documentIssue=${owner.id}`));
await page.reload();
await expect(gallery).toContainText("1 / 3");
}
} finally {
await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: settings.enableClassicTaskInterface } });
}
});
}
@@ -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}
/>
);
@@ -5,6 +5,14 @@ import {
} from "./document-annotation-hash";
describe("parseDocumentAnnotationHash", () => {
it("retains an image position with the reviewed document identity", () => {
const target = { documentKey: "design", threadId: null, commentId: null, revisionId: "old", documentIssueId: "owner", viewer: "full" as const, image: 2 };
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
});
it.each(["0", "-1", "1.5", "text", "9007199254740992"])("ignores invalid image positions: %s", (image) => {
expect(parseDocumentAnnotationHash(`#document-design&image=${image}`)).not.toHaveProperty("image");
});
it("returns null for non-document hashes", () => {
expect(parseDocumentAnnotationHash("")).toBeNull();
expect(parseDocumentAnnotationHash("#issue-foo")).toBeNull();
+4
View File
@@ -6,6 +6,7 @@ export interface DocumentAnnotationHashTarget {
interactionId?: string;
/** Document owner can differ from the task containing the review conversation. */
documentIssueId?: string;
image?: number;
/**
* `viewer=full` (LOOA-2181): external deep links — e.g. the Slack gateway's
* "Open task" button on an approval card — request the document opened in
@@ -32,6 +33,7 @@ export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHas
const params = new URLSearchParams(rest.join("&"));
const threadId = params.get("thread");
const commentId = params.get("comment");
const image = Number(params.get("image"));
return {
documentKey,
threadId: threadId && threadId.length > 0 ? threadId : null,
@@ -40,6 +42,7 @@ export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHas
...(params.get("revision") ? { revisionId: params.get("revision")! } : {}),
...(params.get("interaction") ? { interactionId: params.get("interaction")! } : {}),
...(params.get("documentIssue") ? { documentIssueId: params.get("documentIssue")! } : {}),
...(Number.isSafeInteger(image) && image > 0 ? { image } : {}),
};
}
@@ -53,6 +56,7 @@ export function buildDocumentAnnotationHash(
if (target.revisionId) params.set("revision", target.revisionId);
if (target.interactionId) params.set("interaction", target.interactionId);
if (target.documentIssueId) params.set("documentIssue", target.documentIssueId);
if (target.image && Number.isSafeInteger(target.image) && target.image > 0) params.set("image", String(target.image));
const qs = params.toString();
const encodedKey = encodeURIComponent(target.documentKey);
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;