feat(ui): viewer=full document deep link opens the maximized side pane (#12812)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents ask humans for decisions through approval cards, and a chat gateway plugin can forward those cards to Slack with an "Open task" button > - The button opens the bare task page; to read the document under approval, the reviewer must click four more times (open the side pane, open the Artifacts tab, open the artifact, maximize the pane) > - Approvals are the highest-frequency human touchpoint, so each removed click matters > - This pull request adds a `viewer=full` option to the existing `#document-<key>` deep link; the link now opens the target document and maximizes the side pane > - The benefit is one-click access from an external notification to a full-size reading surface for the document under approval ## Linked Issues or Issue Description **What existing behavior does this improve?** The issue page already supports `#document-<key>` deep links. They open the document in the side pane, but at the pane's default width. **Current behavior** An external link cannot request the maximized (full-size) document view. A reviewer who follows an approval notification must maximize the pane by hand each time. **Proposed behavior** `#document-<key>&viewer=full` opens the document and maximizes the side pane. Plan documents open in the Plan tab, maximized. Mobile keeps the full-screen sheet. Unknown `viewer` values are ignored, so old links and new links stay compatible in both directions. **Reason and benefit** Chat notifications about approvals can now land the reviewer directly on a full-size view of the document they must read. This removes four clicks from every approval review. **Breaking changes** None. The parameter is optional and additive. Links without it keep today's behavior. ## What Changed - `ui/src/lib/document-annotation-hash.ts`: parse and build an optional `viewer=full` parameter in document hashes. - `ui/src/lib/issue-document-deep-link.ts`: thread a `maximize` flag on properties-pane routes; the continuation-summary route is unchanged. - `ui/src/context/PanelContext.tsx`: add a one-shot panel maximize request (`requestPanelMaximize` / `clearPanelMaximizeRequest`). - `ui/src/components/PropertiesPanel.tsx`: the resizable panel host consumes a pending request once it is visible and laid out, then clears it. - `ui/src/pages/IssueDetail.tsx`: request the maximize on the desktop deep-link path only; mobile keeps the sheet. - Tests for all of the above. ## Verification - `cd ui && pnpm typecheck` — clean. - `cd ui && pnpm vitest run src/lib/document-annotation-hash.test.ts src/lib/issue-document-deep-link.test.ts src/components/PropertiesPanel.test.tsx` — 31/31 green. - New cases cover: `viewer` parse/build round trip, unknown values ignored, maximize routing for document and plan tabs, a pending request consumed on mount, and a request held while the panel is hidden. - Manual check: open an issue with `#document-<key>&viewer=full` in the URL; the pane opens on that document, maximized. Remove the parameter; the pane opens at its normal width. ## Risks - Low risk. The parameter is optional; no data, schema, or API changes. - The maximize request lives in React context as a one-shot flag. It is cleared on first consumption, so a stale request cannot re-maximize the pane on later navigations. - If a link carries `viewer=full` on a web build older than this change, the parameter is ignored and the document still opens. ## Model Used - Claude Fable 5 (`claude-fable-5`), Anthropic. Agentic coding session with extended thinking and tool use (file edits, shell, test runs). ## 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) - [ ] 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 - [ ] 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
54dd0f4868
commit
2a5aa5e213
|
|
@ -19,8 +19,10 @@ const mockPanelState = vi.hoisted(() => ({
|
|||
panelContent: null as unknown,
|
||||
panelContentMode: "padded" as const,
|
||||
panelVisible: true,
|
||||
panelMaximizeRequested: false,
|
||||
}));
|
||||
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
|
||||
const mockClearPanelMaximizeRequest = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
usePanel: () => ({
|
||||
|
|
@ -31,6 +33,9 @@ vi.mock("../context/PanelContext", () => ({
|
|||
closePanel: vi.fn(),
|
||||
setPanelVisible: mockSetPanelVisible,
|
||||
togglePanelVisible: vi.fn(),
|
||||
panelMaximizeRequested: mockPanelState.panelMaximizeRequested,
|
||||
requestPanelMaximize: vi.fn(),
|
||||
clearPanelMaximizeRequest: mockClearPanelMaximizeRequest,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -74,6 +79,8 @@ describe("PropertiesPanel", () => {
|
|||
document.body.appendChild(container);
|
||||
window.localStorage.clear();
|
||||
mockSetPanelVisible.mockClear();
|
||||
mockClearPanelMaximizeRequest.mockClear();
|
||||
mockPanelState.panelMaximizeRequested = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -151,6 +158,20 @@ describe("PropertiesPanel", () => {
|
|||
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
|
||||
});
|
||||
|
||||
it("consumes a pending deep-link maximize request on mount (LOOA-2181)", async () => {
|
||||
mockPanelState.panelMaximizeRequested = true;
|
||||
await renderPanel({ taskDetailLayout: true });
|
||||
expect(mockClearPanelMaximizeRequest).toHaveBeenCalled();
|
||||
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
|
||||
});
|
||||
|
||||
it("holds a deep-link maximize request while the panel is hidden", async () => {
|
||||
mockPanelState.panelMaximizeRequested = true;
|
||||
await renderPanel({ panelVisible: false });
|
||||
expect(mockClearPanelMaximizeRequest).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("section")?.getAttribute("data-maximized")).not.toBe("true");
|
||||
});
|
||||
|
||||
it("uses an X to close the Streamlined task-detail sidebar", async () => {
|
||||
await renderPanel({ taskDetailLayout: true });
|
||||
const close = container.querySelector<HTMLButtonElement>('[aria-label="Close side panel"]');
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { SidePanelFrame, SidePanelWindowControls } from "@/components/side-panel";
|
||||
|
||||
export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
|
||||
const { panelContent, panelContentMode, panelVisible, setPanelVisible } = usePanel();
|
||||
const {
|
||||
panelContent,
|
||||
panelContentMode,
|
||||
panelVisible,
|
||||
setPanelVisible,
|
||||
panelMaximizeRequested,
|
||||
clearPanelMaximizeRequest,
|
||||
} = usePanel();
|
||||
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
|
||||
|
|
@ -45,6 +52,8 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
|
|||
panelVisible={panelVisible}
|
||||
setPanelVisible={setPanelVisible}
|
||||
taskDetailLayout={streamlinedTaskDetailLayout}
|
||||
maximizeRequested={panelMaximizeRequested}
|
||||
clearMaximizeRequest={clearPanelMaximizeRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -144,6 +153,9 @@ interface ResizablePropertiesPanelProps {
|
|||
panelVisible: boolean;
|
||||
setPanelVisible: (visible: boolean) => void;
|
||||
taskDetailLayout: boolean;
|
||||
/** Pending `viewer=full` deep-link request (LOOA-2181); cleared once consumed. */
|
||||
maximizeRequested: boolean;
|
||||
clearMaximizeRequest: () => void;
|
||||
}
|
||||
|
||||
function ResizablePropertiesPanel({
|
||||
|
|
@ -152,6 +164,8 @@ function ResizablePropertiesPanel({
|
|||
panelVisible,
|
||||
setPanelVisible,
|
||||
taskDetailLayout,
|
||||
maximizeRequested,
|
||||
clearMaximizeRequest,
|
||||
}: ResizablePropertiesPanelProps) {
|
||||
const defaultPaneWidth = taskDetailLayout
|
||||
? TASK_DETAIL_DEFAULT_PANE_WIDTH
|
||||
|
|
@ -301,6 +315,16 @@ function ResizablePropertiesPanel({
|
|||
restoreTimerRef.current = window.setTimeout(finishRestore, RESTORE_FALLBACK_DELAY);
|
||||
}, [clearRestoreTimer, finishRestore]);
|
||||
|
||||
// Deep-link maximize (LOOA-2181): the request may predate this mount (the
|
||||
// hash routes before the panel content commits), so it lives in context and
|
||||
// is consumed here once the panel is actually visible and laid out —
|
||||
// handleMaximize measures live geometry, which needs a committed DOM.
|
||||
useEffect(() => {
|
||||
if (!maximizeRequested || !panelVisible) return;
|
||||
clearMaximizeRequest();
|
||||
if (!maximized) handleMaximize();
|
||||
}, [maximizeRequested, panelVisible, maximized, handleMaximize, clearMaximizeRequest]);
|
||||
|
||||
const handleTransitionEnd = useCallback(
|
||||
(event: React.TransitionEvent<HTMLElement>) => {
|
||||
if (event.target !== asideRef.current || event.propertyName !== "left") return;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ interface PanelContextValue {
|
|||
closePanel: () => void;
|
||||
setPanelVisible: (visible: boolean) => void;
|
||||
togglePanelVisible: () => void;
|
||||
/**
|
||||
* One-shot maximize request (LOOA-2181): deep links with `viewer=full` ask
|
||||
* the resizable panel host to open maximized. The request stays pending
|
||||
* until the host consumes it (the panel may not be mounted yet when the
|
||||
* deep link routes), so consumers must clear it after acting.
|
||||
*/
|
||||
panelMaximizeRequested: boolean;
|
||||
requestPanelMaximize: () => void;
|
||||
clearPanelMaximizeRequest: () => void;
|
||||
}
|
||||
|
||||
const PanelContext = createContext<PanelContextValue | null>(null);
|
||||
|
|
@ -36,6 +45,15 @@ export function PanelProvider({ children }: { children: ReactNode }) {
|
|||
const [panelContent, setPanelContent] = useState<ReactNode | null>(null);
|
||||
const [panelContentMode, setPanelContentMode] = useState<SidePanelContentMode>("padded");
|
||||
const [panelVisible, setPanelVisibleState] = useState(readPreference);
|
||||
const [panelMaximizeRequested, setPanelMaximizeRequested] = useState(false);
|
||||
|
||||
const requestPanelMaximize = useCallback(() => {
|
||||
setPanelMaximizeRequested(true);
|
||||
}, []);
|
||||
|
||||
const clearPanelMaximizeRequest = useCallback(() => {
|
||||
setPanelMaximizeRequested(false);
|
||||
}, []);
|
||||
|
||||
const openPanel = useCallback((content: ReactNode, options?: { contentMode?: SidePanelContentMode }) => {
|
||||
setPanelContent(content);
|
||||
|
|
@ -62,7 +80,18 @@ export function PanelProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
return (
|
||||
<PanelContext.Provider
|
||||
value={{ panelContent, panelContentMode, panelVisible, openPanel, closePanel, setPanelVisible, togglePanelVisible }}
|
||||
value={{
|
||||
panelContent,
|
||||
panelContentMode,
|
||||
panelVisible,
|
||||
openPanel,
|
||||
closePanel,
|
||||
setPanelVisible,
|
||||
togglePanelVisible,
|
||||
panelMaximizeRequested,
|
||||
requestPanelMaximize,
|
||||
clearPanelMaximizeRequest,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PanelContext.Provider>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe("parseDocumentAnnotationHash", () => {
|
|||
documentKey: "plan",
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
viewer: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -25,6 +26,25 @@ describe("parseDocumentAnnotationHash", () => {
|
|||
documentKey: "plan",
|
||||
threadId: "t1",
|
||||
commentId: "c2",
|
||||
viewer: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the viewer=full request", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-direction-package&viewer=full")).toEqual({
|
||||
documentKey: "direction-package",
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
viewer: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unknown viewer values", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-plan&viewer=huge")).toEqual({
|
||||
documentKey: "plan",
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
viewer: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -33,6 +53,7 @@ describe("parseDocumentAnnotationHash", () => {
|
|||
documentKey: "my notes",
|
||||
threadId: "abc",
|
||||
commentId: null,
|
||||
viewer: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -60,8 +81,24 @@ describe("buildDocumentAnnotationHash", () => {
|
|||
).toBe("#document-plan&thread=t1&comment=c2");
|
||||
});
|
||||
|
||||
it("includes the viewer request", () => {
|
||||
expect(
|
||||
buildDocumentAnnotationHash({
|
||||
documentKey: "direction-package",
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
viewer: "full",
|
||||
}),
|
||||
).toBe("#document-direction-package&viewer=full");
|
||||
});
|
||||
|
||||
it("survives a round trip", () => {
|
||||
const target = { documentKey: "plan-2", threadId: "t-abc", commentId: "c-xyz" };
|
||||
const target = {
|
||||
documentKey: "plan-2",
|
||||
threadId: "t-abc",
|
||||
commentId: "c-xyz",
|
||||
viewer: "full" as const,
|
||||
};
|
||||
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ export interface DocumentAnnotationHashTarget {
|
|||
documentKey: string;
|
||||
threadId: string | null;
|
||||
commentId: string | null;
|
||||
/**
|
||||
* `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
|
||||
* the maximized (full-size) properties pane, skipping the manual
|
||||
* open-pane → Artifacts → open → maximize click chain.
|
||||
*/
|
||||
viewer: "full" | null;
|
||||
}
|
||||
|
||||
const DOCUMENT_HASH_PREFIX = "#document-";
|
||||
|
|
@ -25,13 +32,17 @@ export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHas
|
|||
documentKey,
|
||||
threadId: threadId && threadId.length > 0 ? threadId : null,
|
||||
commentId: commentId && commentId.length > 0 ? commentId : null,
|
||||
viewer: params.get("viewer") === "full" ? "full" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDocumentAnnotationHash(target: DocumentAnnotationHashTarget): string {
|
||||
export function buildDocumentAnnotationHash(
|
||||
target: Omit<DocumentAnnotationHashTarget, "viewer"> & { viewer?: "full" | null },
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (target.threadId) params.set("thread", target.threadId);
|
||||
if (target.commentId) params.set("comment", target.commentId);
|
||||
if (target.viewer) params.set("viewer", target.viewer);
|
||||
const qs = params.toString();
|
||||
const encodedKey = encodeURIComponent(target.documentKey);
|
||||
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ describe("resolveIssueDocumentDeepLink", () => {
|
|||
kind: "properties-pane",
|
||||
tab: "plans",
|
||||
documentKey: "plan",
|
||||
maximize: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -21,6 +22,22 @@ describe("resolveIssueDocumentDeepLink", () => {
|
|||
kind: "properties-pane",
|
||||
tab: "document",
|
||||
documentKey: "qa evidence",
|
||||
maximize: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("requests the maximized pane for viewer=full deep links", () => {
|
||||
expect(resolveIssueDocumentDeepLink("#document-direction-package&viewer=full")).toEqual({
|
||||
kind: "properties-pane",
|
||||
tab: "document",
|
||||
documentKey: "direction-package",
|
||||
maximize: true,
|
||||
});
|
||||
expect(resolveIssueDocumentDeepLink("#document-plan&viewer=full")).toEqual({
|
||||
kind: "properties-pane",
|
||||
tab: "plans",
|
||||
documentKey: "plan",
|
||||
maximize: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { parseDocumentAnnotationHash } from "./document-annotation-hash";
|
|||
|
||||
export type IssueDocumentDeepLinkRoute =
|
||||
| { kind: "continuation-summary" }
|
||||
| { kind: "properties-pane"; tab: "plans"; documentKey: "plan" }
|
||||
| { kind: "properties-pane"; tab: "document"; documentKey: string };
|
||||
| { kind: "properties-pane"; tab: "plans"; documentKey: "plan"; maximize: boolean }
|
||||
| { kind: "properties-pane"; tab: "document"; documentKey: string; maximize: boolean };
|
||||
|
||||
/**
|
||||
* 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 opens in its own tab.
|
||||
* `viewer=full` (LOOA-2181) additionally requests the maximized pane so
|
||||
* external links (Slack approval cards) land on a full-size reading surface.
|
||||
*/
|
||||
export function resolveIssueDocumentDeepLink(hash: string): IssueDocumentDeepLinkRoute | null {
|
||||
const target = parseDocumentAnnotationHash(hash);
|
||||
|
|
@ -19,8 +21,9 @@ export function resolveIssueDocumentDeepLink(hash: string): IssueDocumentDeepLin
|
|||
if (target.documentKey === ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) {
|
||||
return { kind: "continuation-summary" };
|
||||
}
|
||||
const maximize = target.viewer === "full";
|
||||
if (target.documentKey === "plan") {
|
||||
return { kind: "properties-pane", tab: "plans", documentKey: "plan" };
|
||||
return { kind: "properties-pane", tab: "plans", documentKey: "plan", maximize };
|
||||
}
|
||||
return { kind: "properties-pane", tab: "document", documentKey: target.documentKey };
|
||||
return { kind: "properties-pane", tab: "document", documentKey: target.documentKey, maximize };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,10 @@ const mockLocation = vi.hoisted(() => ({
|
|||
const mockOpenPanel = vi.hoisted(() => vi.fn());
|
||||
const mockClosePanel = vi.hoisted(() => vi.fn());
|
||||
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
|
||||
const mockRequestPanelMaximize = vi.hoisted(() => vi.fn());
|
||||
const mockClearPanelMaximizeRequest = vi.hoisted(() => vi.fn());
|
||||
const mockPanelState = vi.hoisted(() => ({ panelVisible: true }));
|
||||
const mockRouteParams = vi.hoisted(() => ({ issueId: "PAP-1" }));
|
||||
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
const mockIssuePropertiesRender = vi.hoisted(() => vi.fn());
|
||||
const mockTaskSidePanelRender = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -216,7 +219,7 @@ vi.mock("@/lib/router", () => ({
|
|||
useLocation: () => mockLocation,
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ issueId: "PAP-1" }),
|
||||
useParams: () => ({ ...mockRouteParams }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
|
|
@ -266,6 +269,8 @@ vi.mock("../context/PanelContext", () => ({
|
|||
closePanel: mockClosePanel,
|
||||
panelVisible: mockPanelState.panelVisible,
|
||||
setPanelVisible: mockSetPanelVisible,
|
||||
requestPanelMaximize: mockRequestPanelMaximize,
|
||||
clearPanelMaximizeRequest: mockClearPanelMaximizeRequest,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -1347,6 +1352,8 @@ describe("IssueDetail", () => {
|
|||
mockOpenPanel.mockClear();
|
||||
mockClosePanel.mockClear();
|
||||
mockSetPanelVisible.mockClear();
|
||||
mockRequestPanelMaximize.mockClear();
|
||||
mockClearPanelMaximizeRequest.mockClear();
|
||||
mockSetBreadcrumbPanelControl.mockClear();
|
||||
mockSetMobileToolbar.mockClear();
|
||||
mockIssuePropertiesRender.mockClear();
|
||||
|
|
@ -1364,6 +1371,7 @@ describe("IssueDetail", () => {
|
|||
mockLocation.search = "";
|
||||
mockLocation.hash = "";
|
||||
mockLocation.state = null;
|
||||
mockRouteParams.issueId = "PAP-1";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -1802,6 +1810,84 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("maximizes the desktop pane once per viewer=full deep link", async () => {
|
||||
mockPanelState.panelVisible = false;
|
||||
mockLocation.hash = "#document-qa-evidence&viewer=full";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
|
||||
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Replaying the same hash (same-page link click) reopens the document but
|
||||
// must not re-maximize a pane the user may have deliberately restored.
|
||||
const link = document.createElement("a");
|
||||
link.href = "#document-qa-evidence&viewer=full";
|
||||
link.textContent = "QA evidence";
|
||||
container.appendChild(link);
|
||||
await act(async () => link.click());
|
||||
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Ending the deep link drops the pending request and re-arms the guard.
|
||||
mockLocation.hash = "";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
expect(mockClearPanelMaximizeRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-maximizes when navigating to another issue with an identical viewer=full hash", async () => {
|
||||
mockPanelState.panelVisible = false;
|
||||
mockLocation.hash = "#document-qa-evidence&viewer=full";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Navigate to a sibling issue whose URL carries the same document hash.
|
||||
// IssueDetail stays mounted; the destination pane must still maximize.
|
||||
mockRouteParams.issueId = "PAP-2";
|
||||
mockLocation.pathname = "/issues/PAP-2";
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({ id: "issue-2", identifier: "PAP-2" }),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the mobile properties sheet for a document deep link", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
mockLocation.hash = "#document-qa-evidence";
|
||||
|
|
|
|||
|
|
@ -2670,7 +2670,14 @@ export function IssueDetail() {
|
|||
? "mx-auto w-full max-w-(--tc-shell-max-w)"
|
||||
: undefined;
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const {
|
||||
openPanel,
|
||||
closePanel,
|
||||
panelVisible,
|
||||
setPanelVisible,
|
||||
requestPanelMaximize,
|
||||
clearPanelMaximizeRequest,
|
||||
} = usePanel();
|
||||
const {
|
||||
setBreadcrumbs,
|
||||
setBreadcrumbToolbar,
|
||||
|
|
@ -5398,6 +5405,12 @@ export function IssueDetail() {
|
|||
sourceBreadcrumb.href,
|
||||
]);
|
||||
|
||||
// One maximize request per issue + `viewer=full` hash: routing re-runs
|
||||
// whenever a callback dependency changes identity, and re-requesting then
|
||||
// would re-maximize a pane the user deliberately restored. The key carries
|
||||
// the issue param so navigating to another issue with an identical hash
|
||||
// still maximizes the destination pane.
|
||||
const lastMaximizeRequestKeyRef = useRef<string | null>(null);
|
||||
const routeIssueDocumentDeepLink = useCallback(
|
||||
(hash: string) => {
|
||||
const route = resolveIssueDocumentDeepLink(hash);
|
||||
|
|
@ -5421,6 +5434,16 @@ export function IssueDetail() {
|
|||
setPanelBeforePlanOverrideIssueId(issue.id);
|
||||
}
|
||||
setPanelVisible(true);
|
||||
// `viewer=full` (LOOA-2181): external links (Slack approval cards)
|
||||
// land with the pane maximized. Mobile uses the sheet, which is
|
||||
// already full-screen, so the request is desktop-only.
|
||||
if (route.maximize) {
|
||||
const requestKey = `${issueId ?? ""}::${hash}`;
|
||||
if (lastMaximizeRequestKeyRef.current !== requestKey) {
|
||||
lastMaximizeRequestKeyRef.current = requestKey;
|
||||
requestPanelMaximize();
|
||||
}
|
||||
}
|
||||
}
|
||||
const targetIssueId = issue?.id ?? issueId ?? "";
|
||||
setDocumentDeepLink((current) => ({
|
||||
|
|
@ -5438,6 +5461,7 @@ export function IssueDetail() {
|
|||
issue?.id,
|
||||
issueId,
|
||||
setPanelVisible,
|
||||
requestPanelMaximize,
|
||||
suppressPanelUntilPlan,
|
||||
taskChatShellEnabled,
|
||||
],
|
||||
|
|
@ -5446,8 +5470,21 @@ export function IssueDetail() {
|
|||
useEffect(() => {
|
||||
if (!routeIssueDocumentDeepLink(location.hash)) {
|
||||
setDocumentDeepLink(null);
|
||||
// The deep link ended (hash cleared or issue changed): drop any
|
||||
// maximize request the panel never consumed so it cannot maximize a
|
||||
// later, unrelated panel, and re-arm for the next viewer=full hash.
|
||||
lastMaximizeRequestKeyRef.current = null;
|
||||
clearPanelMaximizeRequest();
|
||||
}
|
||||
}, [issueId, location.hash, routeIssueDocumentDeepLink]);
|
||||
}, [issueId, location.hash, routeIssueDocumentDeepLink, clearPanelMaximizeRequest]);
|
||||
|
||||
// Leaving the issue page entirely also ends the deep link's lifetime.
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPanelMaximizeRequest();
|
||||
},
|
||||
[clearPanelMaximizeRequest],
|
||||
);
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue