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 (
-
+