From d2fb05d2257689bb38ae983380e1bf97c594d0ec Mon Sep 17 00:00:00 2001
From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com>
Date: Mon, 17 Aug 2026 10:53:46 -0700
Subject: [PATCH] Fix issue document deep-link routing (#11551)
---
ui/src/components/IssueProperties.test.tsx | 85 +++++++-
ui/src/components/IssueProperties.tsx | 2 +-
.../issue-properties/IssueProperties.tsx | 18 +-
.../IssuePropertiesArtifactsTab.tsx | 37 +++-
...ssuePropertiesDocumentAnnotations.test.tsx | 31 +++
ui/src/components/issue-properties/index.ts | 2 +-
ui/src/lib/document-annotation-hash.test.ts | 4 +
ui/src/lib/document-annotation-hash.ts | 8 +-
ui/src/lib/issue-document-deep-link.test.ts | 32 +++
ui/src/lib/issue-document-deep-link.ts | 26 +++
ui/src/pages/IssueDetail.test.tsx | 187 +++++++++++++++++-
ui/src/pages/IssueDetail.tsx | 95 ++++++++-
12 files changed, 502 insertions(+), 25 deletions(-)
create mode 100644 ui/src/lib/issue-document-deep-link.test.ts
create mode 100644 ui/src/lib/issue-document-deep-link.ts
diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx
index 9cda604b8c..702154a54c 100644
--- a/ui/src/components/IssueProperties.test.tsx
+++ b/ui/src/components/IssueProperties.test.tsx
@@ -12,7 +12,7 @@ import type {
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { Issue } from "@paperclipai/shared";
+import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueProperties } from "./IssueProperties";
import { queryKeys } from "../lib/queryKeys";
@@ -36,6 +36,8 @@ const mockIssuesApi = vi.hoisted(() => ({
getDocument: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
listAttachments: vi.fn(),
+ listDocuments: vi.fn(),
+ listWorkProducts: vi.fn(),
listInteractions: vi.fn(),
listLabels: vi.fn(),
createLabel: vi.fn(),
@@ -155,6 +157,15 @@ vi.mock("@/components/ui/separator", () => ({
Separator: () =>
,
}));
+vi.mock("@/components/MarkdownBody", () => ({
+ MarkdownBody: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock("@/components/IssueDocumentAnnotations", () => ({
+ DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => ,
+ IssueDocumentAnnotations: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => {children}
,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
@@ -458,6 +469,8 @@ describe("IssueProperties", () => {
mockIssuesApi.getDocument.mockResolvedValue(null);
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
mockIssuesApi.listAttachments.mockResolvedValue([]);
+ mockIssuesApi.listDocuments.mockResolvedValue([]);
+ mockIssuesApi.listWorkProducts.mockResolvedValue([]);
mockIssuesApi.listInteractions.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
@@ -529,6 +542,76 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
+ it("overrides a previously selected pane tab for a document deep link", async () => {
+ const planDocument = {
+ id: "document-plan",
+ companyId: "company-1",
+ issueId: "issue-1",
+ key: "plan",
+ title: "Plan",
+ format: "markdown",
+ body: "Plan body",
+ latestRevisionId: "revision-plan",
+ latestRevisionNumber: 1,
+ createdByAgentId: null,
+ createdByUserId: null,
+ updatedByAgentId: null,
+ updatedByUserId: null,
+ lockedAt: null,
+ lockedByAgentId: null,
+ lockedByUserId: null,
+ createdAt: new Date("2026-08-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-08-01T00:00:00.000Z"),
+ } satisfies IssueDocument;
+ const artifactDocument = {
+ ...planDocument,
+ id: "document-evidence",
+ key: "qa-evidence",
+ title: "QA evidence",
+ body: "Evidence body",
+ latestRevisionId: "revision-evidence",
+ } satisfies IssueDocument;
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+ enableTaskWatchdogs: false,
+ enableClassicTaskInterface: false,
+ });
+ mockIssuesApi.getDocument.mockResolvedValue(planDocument);
+ mockIssuesApi.listDocuments.mockResolvedValue([planDocument, artifactDocument]);
+ Element.prototype.scrollIntoView = vi.fn();
+
+ const props = {
+ issue: createIssue(),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ inline: true,
+ } satisfies ComponentProps;
+ const { root, queryClient } = renderPropertiesWithQueryClient(container, props);
+ await waitForAssertion(() => {
+ const planTab = Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent === "Plan");
+ expect(planTab?.getAttribute("data-state")).toBe("active");
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ await waitForAssertion(() => {
+ const artifactsTab = Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent === "Artifacts");
+ expect(artifactsTab?.getAttribute("data-state")).toBe("active");
+ expect(container.querySelector('button[aria-expanded="true"]')).not.toBeNull();
+ });
+ act(() => root.unmount());
+ });
+
it("hides the Priority property row while priority UI is off (PAP-411)", async () => {
const root = renderProperties(container, {
issue: createIssue({ priority: "high" }),
diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx
index 1ca7492cea..d276be3c76 100644
--- a/ui/src/components/IssueProperties.tsx
+++ b/ui/src/components/IssueProperties.tsx
@@ -1 +1 @@
-export { IssueProperties } from "./issue-properties";
+export { IssueProperties, type IssuePropertiesDocumentDeepLink } from "./issue-properties";
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 5fa13daff4..4df10852be 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -148,6 +148,13 @@ interface IssuePropertiesProps {
onRetryExternalObjects?: () => void;
onCheckMonitorNow?: () => void;
checkingMonitorNow?: boolean;
+ documentDeepLink?: IssuePropertiesDocumentDeepLink | null;
+}
+
+export interface IssuePropertiesDocumentDeepLink {
+ requestId: number;
+ tab: "plans" | "artifacts";
+ documentKey: string;
}
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
@@ -166,6 +173,7 @@ export function IssueProperties({
onRetryExternalObjects,
onCheckMonitorNow,
checkingMonitorNow = false,
+ documentDeepLink,
}: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const { isMobile } = useSidebar();
@@ -245,6 +253,11 @@ export function IssueProperties({
setPaneTab("plans");
}
}, [hasPlanTab]);
+ useEffect(() => {
+ if (!documentDeepLink) return;
+ paneTabUserChosenRef.current = true;
+ setPaneTab(documentDeepLink.tab);
+ }, [documentDeepLink]);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -2655,7 +2668,10 @@ export function IssueProperties({
) : null}
{hasArtifactsTab ? (
-
+
) : null}
diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
index 4b9b2f67ed..4d8ca4749e 100644
--- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Issue, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
@@ -31,6 +31,10 @@ import { useLocation } from "@/lib/router";
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
+ documentDeepLink?: {
+ requestId: number;
+ documentKey: string;
+ } | null;
}
function formatBytes(n: number): string {
@@ -118,14 +122,31 @@ function WorkProductRow({ workProduct }: { workProduct: IssueWorkProduct }) {
return {body}
;
}
-function DocumentRow({ issueId, doc }: { issueId: string; doc: IssueDocument }) {
+function DocumentRow({
+ issueId,
+ doc,
+ openRequestId,
+}: {
+ issueId: string;
+ doc: IssueDocument;
+ openRequestId?: number;
+}) {
const [expanded, setExpanded] = useState(false);
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
+ const headerRef = useRef(null);
const location = useLocation();
const Chevron = expanded ? ChevronDown : ChevronRight;
+ useEffect(() => {
+ if (openRequestId === undefined) return;
+ setExpanded(true);
+ }, [openRequestId]);
+ useEffect(() => {
+ if (openRequestId === undefined || !expanded) return;
+ headerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ }, [expanded, openRequestId]);
return (
-
+