mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\n\n![Review the result][review]\n\n\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 = "\n\n![Second][shot]\n\n\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("", "");
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in new issue
Block a user