From cd5f93055808b7af613d6d2ea18142d8a17c4a82 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:52:15 -0500 Subject: [PATCH] Add shareable image galleries to task documents (#18157) https://entire.com/gh/paperclipai/-/trails/247/changes/paperclip/247 ## 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 --- maps/agent-access-map/documents-artifacts.md | 5 + maps/feature-map/documents-artifacts.md | 12 ++ tests/e2e/document-image-gallery.spec.ts | 143 ++++++++++++++++++ .../components/DocumentMarkdownBody.test.tsx | 133 ++++++++++++++++ ui/src/components/DocumentMarkdownBody.tsx | 80 ++++++++++ ui/src/components/ImageGalleryModal.test.tsx | 2 +- ui/src/components/ImageGalleryModal.tsx | 33 ++-- .../components/IssueDocumentsSection.test.tsx | 10 ++ ui/src/components/IssueDocumentsSection.tsx | 39 +++-- ui/src/components/MarkdownBody.tsx | 19 ++- .../TaskDocumentPanel.test.tsx | 10 +- .../task-side-panel/TaskDocumentPanel.tsx | 11 +- .../task-side-panel/TaskSidePanel.test.tsx | 7 +- .../task-side-panel/TaskSidePanel.tsx | 2 + ui/src/lib/document-annotation-hash.test.ts | 8 + ui/src/lib/document-annotation-hash.ts | 4 + 16 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 tests/e2e/document-image-gallery.spec.ts create mode 100644 ui/src/components/DocumentMarkdownBody.test.tsx create mode 100644 ui/src/components/DocumentMarkdownBody.tsx diff --git a/maps/agent-access-map/documents-artifacts.md b/maps/agent-access-map/documents-artifacts.md index 0837c4a877e..0d95367b9c7 100644 --- a/maps/agent-access-map/documents-artifacts.md +++ b/maps/agent-access-map/documents-artifacts.md @@ -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-&image=` +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 diff --git a/maps/feature-map/documents-artifacts.md b/maps/feature-map/documents-artifacts.md index efc9ef87bf6..f5e0eb6c980 100644 --- a/maps/feature-map/documents-artifacts.md +++ b/maps/feature-map/documents-artifacts.md @@ -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 diff --git a/tests/e2e/document-image-gallery.spec.ts b/tests/e2e/document-image-gallery.spec.ts new file mode 100644 index 00000000000..f999a83c2c7 --- /dev/null +++ b/tests/e2e/document-image-gallery.spec.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function json(response: Awaited>) { + 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 } }); + } + }); +} diff --git a/ui/src/components/DocumentMarkdownBody.test.tsx b/ui/src/components/DocumentMarkdownBody.test.tsx new file mode 100644 index 00000000000..15e6a860b04 --- /dev/null +++ b/ui/src/components/DocumentMarkdownBody.test.tsx @@ -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 <>{location.pathname}{location.search}{location.hash}; +} +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( + + {markdown} + + + ); + }); +} +async function click(selector: string) { + const element = document.querySelector(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); + }); +}); diff --git a/ui/src/components/DocumentMarkdownBody.tsx b/ui/src/components/DocumentMarkdownBody.tsx new file mode 100644 index 00000000000..66ff5939939 --- /dev/null +++ b/ui/src/components/DocumentMarkdownBody.tsx @@ -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, "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(null); + const returnFocusRef = useRef(null); + const [items, setItems] = useState([]); + 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(".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 ( +
+ + { if (!open) updateImage(); }} + returnFocusRef={returnFocusRef} + /> +
+ ); +} diff --git a/ui/src/components/ImageGalleryModal.test.tsx b/ui/src/components/ImageGalleryModal.test.tsx index 6fbf0b49d24..d203c9c5ecb 100644 --- a/ui/src/components/ImageGalleryModal.test.tsx +++ b/ui/src/components/ImageGalleryModal.test.tsx @@ -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); diff --git a/ui/src/components/ImageGalleryModal.tsx b/ui/src/components/ImageGalleryModal.tsx index 72e0fafd80f..0c4feac0426 100644 --- a/ui/src/components/ImageGalleryModal.tsx +++ b/ui/src/components/ImageGalleryModal.tsx @@ -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; } export function ImageGalleryModal({ @@ -25,6 +27,8 @@ export function ImageGalleryModal({ initialIndex, open, onOpenChange, + onIndexChange, + returnFocusRef, }: ImageGalleryModalProps) { const [currentIndex, setCurrentIndex] = useState(initialIndex); const mediaRef = useRef(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({ { + event.preventDefault(); + returnFocusRef.current?.focus({ preventScroll: true }); + } : undefined} > + Image gallery {/* Top bar */}
diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 33b9907383c..cd456427ef1 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -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()); + 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()); + expect(container.textContent).toContain("Exact reviewed text"); await act(async () => root.unmount()); scroll.mockRestore(); queryClient.clear(); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index ef14dfc5aeb..5ae4dc71b37 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -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 ( - - {body} - + {galleryTarget ? ( + + {body} + + ) : ( + + {body} + + )} ); } @@ -355,8 +363,10 @@ function AccountIssueDocumentsSection({ const [highlightDocumentKey, setHighlightDocumentKey] = useState(null); const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState(null); const [selectedRevisionIds, setSelectedRevisionIds] = useState>({}); - const [handledRevisionHash, setHandledRevisionHash] = useState(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(null); const autosaveDebounceRef = useRef | 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({
- {renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)} + {renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
) : 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 ? ( 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) ? ( diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 9b2bf36c85c..2063d7485af 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -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")} /> ); }; diff --git a/ui/src/components/task-side-panel/TaskDocumentPanel.test.tsx b/ui/src/components/task-side-panel/TaskDocumentPanel.test.tsx index dab61f09c17..3e632dde52e 100644 --- a/ui/src/components/task-side-panel/TaskDocumentPanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskDocumentPanel.test.tsx @@ -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 }) =>
{children}
, })); vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: React.ReactNode }) =>
{children}
})); +vi.mock("@/components/DocumentMarkdownBody", () => ({ DocumentMarkdownBody: ({ children, documentIssueId }: { children: React.ReactNode; documentIssueId?: string }) =>
{children}
})); 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(); }); + await act(async () => { root!.render(); }); 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() }); diff --git a/ui/src/components/task-side-panel/TaskDocumentPanel.tsx b/ui/src/components/task-side-panel/TaskDocumentPanel.tsx index 94f199f41d8..3f159c9075e 100644 --- a/ui/src/components/task-side-panel/TaskDocumentPanel.tsx +++ b/ui/src/components/task-side-panel/TaskDocumentPanel.tsx @@ -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" > - {document.body} + + {document.body} + ) : (

Document is empty.

diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index 9686fbe8116..10d3caac361 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -106,7 +106,7 @@ vi.mock("@/components/WorkspaceFileBrowser", () => ({ vi.mock("./TaskAttachmentPanel", () => ({ TaskAttachmentPanel: ({ attachmentId }: { attachmentId: string }) =>
Text file {attachmentId}
})); vi.mock("./TaskDocumentPanel", () => ({ - TaskDocumentPanel: ({ documentKey, issueId, revisionId, initialDocument }: { documentKey: string; issueId: string; revisionId?: string; initialDocument?: IssueDocument }) =>
{`Document ${documentKey}`}
, + TaskDocumentPanel: ({ documentKey, issueId, revisionId, documentIssueId, currentIssueId, initialDocument }: { documentKey: string; issueId: string; revisionId?: string; documentIssueId?: string; currentIssueId?: string; initialDocument?: IssueDocument }) =>
{`Document ${documentKey}`}
, })); 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 () => { diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx index fc4a0ddb156..372b82cb6a1 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx @@ -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} /> ); diff --git a/ui/src/lib/document-annotation-hash.test.ts b/ui/src/lib/document-annotation-hash.test.ts index 7899d0e9557..715e2d7b81c 100644 --- a/ui/src/lib/document-annotation-hash.test.ts +++ b/ui/src/lib/document-annotation-hash.test.ts @@ -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(); diff --git a/ui/src/lib/document-annotation-hash.ts b/ui/src/lib/document-annotation-hash.ts index 384be7af6cd..0e67d0c212f 100644 --- a/ui/src/lib/document-annotation-hash.ts +++ b/ui/src/lib/document-annotation-hash.ts @@ -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}`;