From 2a5aa5e213f31ebfd3ba3360cff099adfdfe7372 Mon Sep 17 00:00:00 2001 From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:27:25 -1000 Subject: [PATCH] feat(ui): viewer=full document deep link opens the maximized side pane (#12812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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-` 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-` 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-&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-&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 --- ui/src/components/PropertiesPanel.test.tsx | 21 +++++ ui/src/components/PropertiesPanel.tsx | 26 +++++- ui/src/context/PanelContext.tsx | 31 +++++++- ui/src/lib/document-annotation-hash.test.ts | 39 ++++++++- ui/src/lib/document-annotation-hash.ts | 13 ++- ui/src/lib/issue-document-deep-link.test.ts | 17 ++++ ui/src/lib/issue-document-deep-link.ts | 11 ++- ui/src/pages/IssueDetail.test.tsx | 88 ++++++++++++++++++++- ui/src/pages/IssueDetail.tsx | 41 +++++++++- 9 files changed, 276 insertions(+), 11 deletions(-) diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx index d30119222b..e478d654f6 100644 --- a/ui/src/components/PropertiesPanel.test.tsx +++ b/ui/src/components/PropertiesPanel.test.tsx @@ -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('[aria-label="Close side panel"]'); diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index 26bbd2ca32..783ec1dd39 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -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) => { if (event.target !== asideRef.current || event.propertyName !== "left") return; diff --git a/ui/src/context/PanelContext.tsx b/ui/src/context/PanelContext.tsx index fcb79e9245..1893c47705 100644 --- a/ui/src/context/PanelContext.tsx +++ b/ui/src/context/PanelContext.tsx @@ -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(null); @@ -36,6 +45,15 @@ export function PanelProvider({ children }: { children: ReactNode }) { const [panelContent, setPanelContent] = useState(null); const [panelContentMode, setPanelContentMode] = useState("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 ( {children} diff --git a/ui/src/lib/document-annotation-hash.test.ts b/ui/src/lib/document-annotation-hash.test.ts index c954bcce1e..7899d0e955 100644 --- a/ui/src/lib/document-annotation-hash.test.ts +++ b/ui/src/lib/document-annotation-hash.test.ts @@ -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); }); }); diff --git a/ui/src/lib/document-annotation-hash.ts b/ui/src/lib/document-annotation-hash.ts index 1b0b859e91..dd58b53c54 100644 --- a/ui/src/lib/document-annotation-hash.ts +++ b/ui/src/lib/document-annotation-hash.ts @@ -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 & { 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}`; diff --git a/ui/src/lib/issue-document-deep-link.test.ts b/ui/src/lib/issue-document-deep-link.test.ts index fca00b3ae1..a5bfb60f98 100644 --- a/ui/src/lib/issue-document-deep-link.test.ts +++ b/ui/src/lib/issue-document-deep-link.test.ts @@ -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, }); }); diff --git a/ui/src/lib/issue-document-deep-link.ts b/ui/src/lib/issue-document-deep-link.ts index cfee5e2a01..19132d1f5f 100644 --- a/ui/src/lib/issue-document-deep-link.ts +++ b/ui/src/lib/issue-document-deep-link.ts @@ -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 }; } diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 479cae38b7..9abb6d8d09 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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( + + + , + ); + }); + + 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( + + + , + ); + }); + 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( + + + , + ); + }); + + 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( + + + , + ); + }); + + 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"; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6a29babbe1..bad72401fd 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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(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