Fix issue document deep-link routing (#11551)
This commit is contained in:
parent
1a17cbf232
commit
d2fb05d225
|
|
@ -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: () => <hr />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/IssueDocumentAnnotations", () => ({
|
||||
DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => <span data-doc-key={docKey} />,
|
||||
IssueDocumentAnnotations: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
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<typeof IssueProperties>;
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueProperties
|
||||
{...props}
|
||||
documentDeepLink={{ tab: "artifacts", documentKey: "qa-evidence", requestId: 1 }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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" }),
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { IssueProperties } from "./issue-properties";
|
||||
export { IssueProperties, type IssuePropertiesDocumentDeepLink } from "./issue-properties";
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<TabsContent value="artifacts">
|
||||
<IssuePropertiesArtifactsTab issue={issue} />
|
||||
<IssuePropertiesArtifactsTab
|
||||
issue={issue}
|
||||
documentDeepLink={documentDeepLink?.tab === "artifacts" ? documentDeepLink : null}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -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 <div className={ROW_CLASS}>{body}</div>;
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>(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 (
|
||||
<div className="rounded-md border border-border bg-card/50">
|
||||
<div className="flex items-center hover:bg-accent/50">
|
||||
<div ref={headerRef} className="flex items-center hover:bg-accent/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
|
|
@ -182,7 +203,7 @@ function DocumentRow({ issueId, doc }: { issueId: string; doc: IssueDocument })
|
|||
* user uploads are excluded — those stay first-class in the conversation
|
||||
* thread.
|
||||
*/
|
||||
export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsTabProps) {
|
||||
export function IssuePropertiesArtifactsTab({ issue, documentDeepLink }: IssuePropertiesArtifactsTabProps) {
|
||||
const { data: attachments } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issue.id),
|
||||
queryFn: () => issuesApi.listAttachments(issue.id),
|
||||
|
|
@ -225,7 +246,13 @@ export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsT
|
|||
<ul className="flex flex-col gap-1">
|
||||
{documentRows.map((doc) => (
|
||||
<li key={doc.key}>
|
||||
<DocumentRow issueId={issue.id} doc={doc} />
|
||||
<DocumentRow
|
||||
issueId={issue.id}
|
||||
doc={doc}
|
||||
openRequestId={documentDeepLink?.documentKey === doc.key
|
||||
? documentDeepLink.requestId
|
||||
: undefined}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ describe("issue properties document annotation mounting", () => {
|
|||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -74,4 +75,34 @@ describe("issue properties document annotation mounting", () => {
|
|||
.toBe("document-1");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("expands and scrolls the requested document into view", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(
|
||||
<IssuePropertiesArtifactsTab
|
||||
issue={issue}
|
||||
documentDeepLink={{ documentKey: "qa-evidence", requestId: 1 }}
|
||||
/>,
|
||||
));
|
||||
|
||||
expect(container.querySelector('button[aria-expanded="true"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="annotation-surface-qa-evidence"]')).not.toBeNull();
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("does not expand an unrelated document when the requested key is missing", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(
|
||||
<IssuePropertiesArtifactsTab
|
||||
issue={issue}
|
||||
documentDeepLink={{ documentKey: "deleted-document", requestId: 1 }}
|
||||
/>,
|
||||
));
|
||||
|
||||
expect(container.querySelector('button[aria-expanded="true"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="annotation-surface-qa-evidence"]')).toBeNull();
|
||||
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { IssueProperties } from "./IssueProperties";
|
||||
export { IssueProperties, type IssuePropertiesDocumentDeepLink } from "./IssueProperties";
|
||||
export { ExternalObjectRows } from "./external-object-rows";
|
||||
export { PropertyPicker } from "./property-picker";
|
||||
export { PropertyChip, PropertyRow, PropertySection } from "./primitives";
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ describe("parseDocumentAnnotationHash", () => {
|
|||
commentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a malformed encoded document key", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-%E0%A4%A")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDocumentAnnotationHash", () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHas
|
|||
const stripped = hash.slice(DOCUMENT_HASH_PREFIX.length);
|
||||
const [rawKey, ...rest] = stripped.split("&");
|
||||
if (!rawKey) return null;
|
||||
const documentKey = decodeURIComponent(rawKey);
|
||||
let documentKey: string;
|
||||
try {
|
||||
documentKey = decodeURIComponent(rawKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!documentKey) return null;
|
||||
const params = new URLSearchParams(rest.join("&"));
|
||||
const threadId = params.get("thread");
|
||||
const commentId = params.get("comment");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIssueDocumentDeepLink } from "./issue-document-deep-link";
|
||||
|
||||
describe("resolveIssueDocumentDeepLink", () => {
|
||||
it("preserves continuation-summary routing", () => {
|
||||
expect(resolveIssueDocumentDeepLink("#document-continuation-summary")).toEqual({
|
||||
kind: "continuation-summary",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes plan to its dedicated pane tab", () => {
|
||||
expect(resolveIssueDocumentDeepLink("#document-plan")).toEqual({
|
||||
kind: "properties-pane",
|
||||
tab: "plans",
|
||||
documentKey: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes ordinary and annotated documents to Artifacts", () => {
|
||||
expect(resolveIssueDocumentDeepLink("#document-qa%20evidence&thread=thread-1")).toEqual({
|
||||
kind: "properties-pane",
|
||||
tab: "artifacts",
|
||||
documentKey: "qa evidence",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores empty, unrelated, and malformed hashes", () => {
|
||||
expect(resolveIssueDocumentDeepLink("#document-")).toBeNull();
|
||||
expect(resolveIssueDocumentDeepLink("#work-product-1")).toBeNull();
|
||||
expect(resolveIssueDocumentDeepLink("#document-%E0%A4%A")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { parseDocumentAnnotationHash } from "./document-annotation-hash";
|
||||
|
||||
export type IssueDocumentDeepLinkRoute =
|
||||
| { kind: "continuation-summary" }
|
||||
| { kind: "properties-pane"; tab: "plans"; documentKey: "plan" }
|
||||
| { kind: "properties-pane"; tab: "artifacts"; documentKey: string };
|
||||
|
||||
/**
|
||||
* Maps an issue document hash to the surface that owns that document.
|
||||
*
|
||||
* The continuation summary remains in the activity/handoff surface, the plan
|
||||
* keeps its dedicated pane tab, and every other document belongs to Artifacts.
|
||||
*/
|
||||
export function resolveIssueDocumentDeepLink(hash: string): IssueDocumentDeepLinkRoute | null {
|
||||
const target = parseDocumentAnnotationHash(hash);
|
||||
if (!target) return null;
|
||||
|
||||
if (target.documentKey === ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) {
|
||||
return { kind: "continuation-summary" };
|
||||
}
|
||||
if (target.documentKey === "plan") {
|
||||
return { kind: "properties-pane", tab: "plans", documentKey: "plan" };
|
||||
}
|
||||
return { kind: "properties-pane", tab: "artifacts", documentKey: target.documentKey };
|
||||
}
|
||||
|
|
@ -90,6 +90,10 @@ const mockLocation = vi.hoisted(() => ({
|
|||
}));
|
||||
const mockOpenPanel = vi.hoisted(() => vi.fn());
|
||||
const mockClosePanel = vi.hoisted(() => vi.fn());
|
||||
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
|
||||
const mockPanelState = vi.hoisted(() => ({ panelVisible: true }));
|
||||
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
const mockIssuePropertiesRender = vi.hoisted(() => vi.fn());
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockSetMobileToolbar = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -207,15 +211,13 @@ vi.mock("../context/PanelContext", () => ({
|
|||
usePanel: () => ({
|
||||
openPanel: mockOpenPanel,
|
||||
closePanel: mockClosePanel,
|
||||
panelVisible: true,
|
||||
setPanelVisible: vi.fn(),
|
||||
panelVisible: mockPanelState.panelVisible,
|
||||
setPanelVisible: mockSetPanelVisible,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
}),
|
||||
useSidebar: () => mockSidebarState,
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
|
|
@ -365,7 +367,10 @@ vi.mock("../components/IssuesList", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../components/IssueProperties", () => ({
|
||||
IssueProperties: () => <div>Properties</div>,
|
||||
IssueProperties: (props: unknown) => {
|
||||
mockIssuePropertiesRender(props);
|
||||
return <div>Properties</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/IssueRunLedger", () => ({
|
||||
|
|
@ -1022,6 +1027,8 @@ describe("IssueDetail", () => {
|
|||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPanelState.panelVisible = true;
|
||||
mockSidebarState.isMobile = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
|
@ -1078,6 +1085,8 @@ describe("IssueDetail", () => {
|
|||
mockIssuesApi.getDocument.mockResolvedValue(null);
|
||||
mockOpenPanel.mockClear();
|
||||
mockClosePanel.mockClear();
|
||||
mockSetPanelVisible.mockClear();
|
||||
mockIssuePropertiesRender.mockClear();
|
||||
mockIssuesListRender.mockClear();
|
||||
mockIssueChatThreadRender.mockClear();
|
||||
mockImageGalleryRender.mockClear();
|
||||
|
|
@ -1127,6 +1136,172 @@ describe("IssueDetail", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("opens a closed desktop pane and routes an ordinary document to Artifacts on direct load", async () => {
|
||||
mockPanelState.panelVisible = false;
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.documentDeepLink).toMatchObject({
|
||||
tab: "artifacts",
|
||||
documentKey: "qa-evidence",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ordinary document links to the classic center-column surface", async () => {
|
||||
mockPanelState.panelVisible = false;
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
enableClassicTaskInterface: true,
|
||||
});
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull();
|
||||
expect(mockSetPanelVisible).not.toHaveBeenCalled();
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.documentDeepLink).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears document routing when the URL no longer names a document", async () => {
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.documentDeepLink).toMatchObject({ documentKey: "qa-evidence" });
|
||||
});
|
||||
|
||||
mockLocation.hash = "#work-product-1";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.documentDeepLink).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("routes plan to the Plan pane tab and leaves continuation-summary on its existing surface", async () => {
|
||||
mockPanelState.panelVisible = false;
|
||||
mockLocation.hash = "#document-plan";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.documentDeepLink).toMatchObject({ tab: "plans", documentKey: "plan" });
|
||||
});
|
||||
|
||||
mockSetPanelVisible.mockClear();
|
||||
mockLocation.hash = "#document-continuation-summary";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
expect(mockSetPanelVisible).not.toHaveBeenCalled();
|
||||
await waitForAssertion(() => {
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.documentDeepLink).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("replays document routing when the current same-page hash is clicked again", async () => {
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect((panel?.props?.documentDeepLink as { requestId?: number } | null)?.requestId).toBe(1);
|
||||
});
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = "#document-qa-evidence";
|
||||
link.textContent = "QA evidence";
|
||||
container.appendChild(link);
|
||||
await act(async () => link.click());
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect((panel?.props?.documentDeepLink as { requestId?: number } | null)?.requestId).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the mobile properties sheet for a document deep link", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuePropertiesRender).toHaveBeenCalledWith(expect.objectContaining({
|
||||
inline: true,
|
||||
documentDeepLink: expect.objectContaining({
|
||||
tab: "artifacts",
|
||||
documentKey: "qa-evidence",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
expect(mockSetPanelVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the full sub-task tree below the title in the chat center pane", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ import {
|
|||
hasVisibleMonitorSurface,
|
||||
} from "../components/IssueMonitorBanner";
|
||||
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueProperties, type IssuePropertiesDocumentDeepLink } from "../components/IssueProperties";
|
||||
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
|
||||
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
|
||||
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
|
||||
|
|
@ -171,6 +171,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { formatIssueActivityAction } from "@/lib/activity-format";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key";
|
||||
import { resolveIssueDocumentDeepLink } from "../lib/issue-document-deep-link";
|
||||
import { buildIssueSiblingNavigation, shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
|
||||
import { filterIssueDescendants } from "../lib/issue-tree";
|
||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||
|
|
@ -1698,7 +1699,10 @@ export function IssueDetail() {
|
|||
// legacy title/description block, sub-tasks table, plan decompositions and
|
||||
// Documents section are gated off (plan lives in the properties-pane Plan
|
||||
// tab). Flag ON restores the legacy page.
|
||||
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
|
||||
const {
|
||||
enabled: classicTaskInterfaceEnabled,
|
||||
loaded: classicTaskInterfaceLoaded,
|
||||
} = useClassicTaskInterfaceEnabled();
|
||||
const taskChatShellEnabled = !classicTaskInterfaceEnabled;
|
||||
// Chat-style: the page wrapper spans the full center pane so the thread's
|
||||
// scroll viewport (and its scrollbar) reaches the properties-pane border;
|
||||
|
|
@ -1718,6 +1722,9 @@ export function IssueDetail() {
|
|||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [documentDeepLink, setDocumentDeepLink] = useState<
|
||||
(IssuePropertiesDocumentDeepLink & { issueId: string }) | null
|
||||
>(null);
|
||||
const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("chat");
|
||||
// Redesign: the center tab strip is hidden, so chat is the only surface —
|
||||
|
|
@ -3508,6 +3515,7 @@ export function IssueDetail() {
|
|||
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
|
||||
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
|
||||
checkingMonitorNow={checkIssueMonitorNow.isPending}
|
||||
documentDeepLink={documentDeepLink?.issueId === panelIssue.id ? documentDeepLink : null}
|
||||
/>
|
||||
);
|
||||
return () => closePanel();
|
||||
|
|
@ -3528,6 +3536,7 @@ export function IssueDetail() {
|
|||
externalObjectsState.isLoading,
|
||||
externalObjectsState.isError,
|
||||
externalObjectsState.refetch,
|
||||
documentDeepLink,
|
||||
]);
|
||||
|
||||
const goToInboxShortcutArmedRef = useRef(false);
|
||||
|
|
@ -3655,14 +3664,81 @@ export function IssueDetail() {
|
|||
};
|
||||
}, [fileViewerEnabled, keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
|
||||
|
||||
const routeIssueDocumentDeepLink = useCallback((hash: string) => {
|
||||
const route = resolveIssueDocumentDeepLink(hash);
|
||||
if (!route) return false;
|
||||
|
||||
if (route.kind === "continuation-summary") {
|
||||
setDocumentDeepLink(null);
|
||||
setDetailTab("activity");
|
||||
setHandoffFocusSignal((current) => current + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The classic interface owns document links in its center-column
|
||||
// Documents section. Do not open its tab-less properties panel.
|
||||
if (!classicTaskInterfaceLoaded || !taskChatShellEnabled) return false;
|
||||
|
||||
if (isMobile) {
|
||||
setMobilePropsOpen(true);
|
||||
} else {
|
||||
if (suppressPanelForFirstTask && issue?.id) {
|
||||
setFirstTaskPanelOverrideIssueId(issue.id);
|
||||
}
|
||||
setPanelVisible(true);
|
||||
}
|
||||
const targetIssueId = issue?.id ?? issueId ?? "";
|
||||
setDocumentDeepLink((current) => ({
|
||||
issueId: targetIssueId,
|
||||
tab: route.tab,
|
||||
documentKey: route.documentKey,
|
||||
requestId: current?.issueId === targetIssueId ? current.requestId + 1 : 1,
|
||||
}));
|
||||
return true;
|
||||
}, [
|
||||
classicTaskInterfaceLoaded,
|
||||
isMobile,
|
||||
issue?.id,
|
||||
issueId,
|
||||
setPanelVisible,
|
||||
suppressPanelForFirstTask,
|
||||
taskChatShellEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#document-")) return;
|
||||
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||
if (documentKey !== ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) return;
|
||||
setDetailTab("activity");
|
||||
setHandoffFocusSignal((current) => current + 1);
|
||||
}, [location.hash]);
|
||||
if (!routeIssueDocumentDeepLink(location.hash)) {
|
||||
setDocumentDeepLink(null);
|
||||
}
|
||||
}, [issueId, location.hash, routeIssueDocumentDeepLink]);
|
||||
|
||||
// React Router does not emit a location update when the user clicks a link
|
||||
// whose hash is already current. Capture that repeated intent so a manually
|
||||
// collapsed document reopens and scrolls back into view.
|
||||
useEffect(() => {
|
||||
const handleSameHashDocumentClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const anchor = target.closest<HTMLAnchorElement>("a[href]");
|
||||
if (!anchor) return;
|
||||
const rawHref = anchor.getAttribute("href");
|
||||
if (!rawHref) return;
|
||||
|
||||
let targetUrl: URL;
|
||||
try {
|
||||
targetUrl = new URL(rawHref, window.location.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const sameIssue = rawHref.startsWith("#")
|
||||
|| (targetUrl.pathname === location.pathname && targetUrl.search === location.search);
|
||||
if (!sameIssue || targetUrl.hash !== location.hash) return;
|
||||
routeIssueDocumentDeepLink(targetUrl.hash);
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleSameHashDocumentClick, true);
|
||||
return () => document.removeEventListener("click", handleSameHashDocumentClick, true);
|
||||
}, [location.hash, location.pathname, location.search, routeIssueDocumentDeepLink]);
|
||||
|
||||
// Scroll + briefly highlight work-product / direct-attachment anchors so the
|
||||
// company Artifacts page (PAP-10359) can deep-link to a specific artifact in
|
||||
|
|
@ -5519,6 +5595,7 @@ export function IssueDetail() {
|
|||
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
|
||||
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
|
||||
checkingMonitorNow={checkIssueMonitorNow.isPending}
|
||||
documentDeepLink={documentDeepLink?.issueId === issue.id ? documentDeepLink : null}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
|
|
|||
Loading…
Reference in New Issue