Fix issue document deep-link routing (#11551)

This commit is contained in:
Michael Nguyen 2026-08-17 10:53:46 -07:00 committed by GitHub
parent 1a17cbf232
commit d2fb05d225
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 502 additions and 25 deletions

View File

@ -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" }),

View File

@ -1 +1 @@
export { IssueProperties } from "./issue-properties";
export { IssueProperties, type IssuePropertiesDocumentDeepLink } from "./issue-properties";

View File

@ -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>

View File

@ -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>

View File

@ -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());
});
});

View File

@ -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";

View File

@ -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", () => {

View File

@ -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");

View File

@ -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();
});
});

View File

@ -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 };
}

View File

@ -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([

View File

@ -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>