diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1775102e9e..fc440823eb 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -74,9 +74,10 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar The dashboard now handles browser back navigation consistently on desktop and mobile. Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app. -On mobile, an open navigation-bar **More** sheet or mailbox message detail is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes. +On mobile, an open navigation-bar **More** sheet, mailbox message detail, or artifact viewer is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes. + When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list). On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible. diff --git a/packages/dashboard/app/components/ArtifactsGallery.tsx b/packages/dashboard/app/components/ArtifactsGallery.tsx index 275ba15db6..9017187fd6 100644 --- a/packages/dashboard/app/components/ArtifactsGallery.tsx +++ b/packages/dashboard/app/components/ArtifactsGallery.tsx @@ -1,5 +1,5 @@ import "./ArtifactsGallery.css"; -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { @@ -22,6 +22,7 @@ import { artifactMediaUrl, artifactMediaUrlWithToken, fetchArtifact, updateArtif import { withTokenHeader } from "../auth"; import { FileEditor } from "./FileEditor"; import { FloatingWindow } from "./FloatingWindow"; +import { NavigationHistoryContext } from "../hooks/useNavigationHistory"; /* FNXC:ArtifactRegistry 2026-07-10-15:40: @@ -98,6 +99,7 @@ interface ViewerState { export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onOpenTask, onArtifactUpdated }: ArtifactsGalleryProps) { const { t } = useTranslation("app"); + const navigationHistory = useContext(NavigationHistoryContext); const [categoryFilter, setCategoryFilter] = useState("all"); const [viewer, setViewer] = useState(null); const viewerReturnFocusRef = useRef(null); @@ -136,6 +138,22 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO viewerReturnFocusRef.current = null; }, []); + /* + FNXC:ArtifactViewerMobileBack 2026-07-16-17:00: + A mobile artifact viewer must dismiss before navigation on iOS swipe-back, Android native Back, and browser Back. + Register one stable modal closer for every viewer kind; nullable context keeps provider-less gallery renders working. + Programmatic close uses removeNav to consume its pushed entry, while popstate invokes closeViewer directly without a double-back. + */ + useEffect(() => { + if (!isMobile || !viewer || !navigationHistory) return; + navigationHistory.pushNav({ type: "modal", close: closeViewer }); + }, [closeViewer, isMobile, navigationHistory, viewer]); + + const dismissViewer = useCallback(() => { + navigationHistory?.removeNav(closeViewer); + closeViewer(); + }, [closeViewer, navigationHistory]); + const visibleCategories = ARTIFACT_CATEGORY_ORDER.filter((category) => grouped.has(category) && (categoryFilter === "all" || categoryFilter === category)); @@ -195,10 +213,10 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO })} {viewer && viewer.kind === "media" && ( - + )} {viewer && viewer.kind === "pdf" && ( - + )} {viewer && viewer.kind === "doc" && ( diff --git a/packages/dashboard/app/components/__tests__/ArtifactsGallery.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/ArtifactsGallery.swipe-back.test.tsx new file mode 100644 index 0000000000..661019cd2a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ArtifactsGallery.swipe-back.test.tsx @@ -0,0 +1,170 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ArtifactWithTask } from "@fusion/core"; +import { ArtifactsGallery } from "../ArtifactsGallery"; +import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory"; + +vi.mock("../../api", () => ({ + artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`), + artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media?fn_token=test`), + fetchArtifact: vi.fn(() => Promise.resolve({})), + updateArtifact: vi.fn(), +})); + +vi.mock("../FloatingWindow", () => ({ + FloatingWindow: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +const artifacts: ArtifactWithTask[] = [ + { + id: "image-artifact", + type: "image", + title: "Image artifact", + mimeType: "image/png", + uri: "artifacts/image.png", + authorId: "agent", + authorType: "agent", + createdAt: "2026-07-16T12:00:00.000Z", + updatedAt: "2026-07-16T12:00:00.000Z", + }, + { + id: "doc-artifact", + type: "document", + title: "Document artifact", + mimeType: "text/markdown", + content: "# Document", + authorId: "agent", + authorType: "agent", + createdAt: "2026-07-16T12:00:00.000Z", + updatedAt: "2026-07-16T12:00:00.000Z", + }, +]; + +function HistoryHarness({ children, onReady }: { children: ReactNode; onReady: (history: UseNavigationHistoryResult) => void }) { + const history = useNavigationHistory({ enabled: true }); + useEffect(() => onReady(history), [history, onReady]); + return {children}; +} + +function dispatchPopState(navIndex: number) { + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex } })); + }); +} + +const galleryProps = { + artifacts, + isMobile: true, + addToast: vi.fn(), + onOpenTask: vi.fn(), +}; + +function openImageViewer() { + fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" })); +} + +function openDocumentViewer() { + fireEvent.click(screen.getByRole("button", { name: "Open Document artifact" })); +} + +function expectViewerOpen(container: HTMLElement) { + expect(container.querySelector(".artifacts-gallery-viewer")).not.toBeNull(); +} + +describe("ArtifactsGallery mobile viewer navigation history", () => { + let navigationHistory: UseNavigationHistoryResult | null = null; + + beforeEach(() => { + navigationHistory = null; + window.history.replaceState({ navIndex: 0 }, ""); + vi.spyOn(window.history, "back").mockImplementation(() => {}); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + function renderWithHistory(isMobile = true) { + return render( + { navigationHistory = history; }}> + + , + ); + } + + it("dismisses the media viewer on browser Back and drains its nav entry", async () => { + const { container } = renderWithHistory(); + openImageViewer(); + expectViewerOpen(container); + + dispatchPopState(0); + + await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull()); + expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument(); + dispatchPopState(0); + expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull(); + }); + + it("routes Android native Back through popstate before dismissing the document viewer", async () => { + const { container } = renderWithHistory(); + openDocumentViewer(); + expectViewerOpen(container); + + const nativeBack = new CustomEvent("fusion:native-back", { cancelable: true }); + expect(window.dispatchEvent(nativeBack)).toBe(false); + expect(window.history.back).toHaveBeenCalledOnce(); + expectViewerOpen(container); + + dispatchPopState(0); + await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull()); + expect(screen.getByRole("button", { name: "Open Document artifact" })).toBeInTheDocument(); + }); + + async function expectProgrammaticCloseConsumesViewerEntry(close: () => void) { + const { container } = renderWithHistory(); + await waitFor(() => expect(navigationHistory).not.toBeNull()); + const sentinelClose = vi.fn(); + navigationHistory?.pushNav({ type: "modal", close: sentinelClose }); + openImageViewer(); + expectViewerOpen(container); + + close(); + await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull()); + expect(window.history.back).toHaveBeenCalledOnce(); + + // The first pop is removeNav's self-pop; exactly one more pop reaches the lower entry. + dispatchPopState(1); + expect(sentinelClose).not.toHaveBeenCalled(); + dispatchPopState(0); + expect(sentinelClose).toHaveBeenCalledOnce(); + } + + it("consumes the viewer entry when the header close button dismisses it", async () => { + await expectProgrammaticCloseConsumesViewerEntry(() => fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }))); + }); + + it("consumes the viewer entry when Escape dismisses it", async () => { + await expectProgrammaticCloseConsumesViewerEntry(() => fireEvent.keyDown(document, { key: "Escape" })); + }); + + it("keeps provider-less gallery renders functional", async () => { + const { container } = render(); + openImageViewer(); + expectViewerOpen(container); + fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" })); + await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull()); + }); + + it("does not push a viewer entry on desktop", async () => { + renderWithHistory(false); + await waitFor(() => expect(navigationHistory).not.toBeNull()); + const sentinelClose = vi.fn(); + navigationHistory?.pushNav({ type: "modal", close: sentinelClose }); + openImageViewer(); + + dispatchPopState(0); + expect(sentinelClose).toHaveBeenCalledOnce(); + }); +});