FN-8267: dismiss mobile artifact viewers on back

Ensure mobile artifact viewers close before dashboard navigation.

- Register mobile viewer entries with navigation history.
- Consume entries for header and Escape dismissals.
- Cover browser and native Back behavior and document it.

Files changed:
 docs/dashboard-guide.md                            |   3 +-
 .../dashboard/app/components/ArtifactsGallery.tsx  |  26 +++-
 .../__tests__/ArtifactsGallery.swipe-back.test.tsx | 170 +++++++++++++++++++++
 3 files changed, 194 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8267

Fusion-Task-Lineage: 5a5edb10-b6c5-4b9d-89a8-6623306953f6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 02:05:35 -07:00
parent 44e20274d7
commit 4fd9ccd5ae
3 changed files with 194 additions and 5 deletions

View File

@@ -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.
<!-- FNXC:MobileNavBackDocs 2026-07-16-14:45: The mobile More sheet registers as a navigation modal, so every Back delivery mechanism dismisses it before navigating away. -->
<!-- FNXC:MailboxMobileBackDocs 2026-07-16-16:15: A mobile mailbox message detail registers as a navigation modal, so Back returns to the message list instead of navigating away. -->
<!-- FNXC:ArtifactViewerMobileBackDocs 2026-07-16-17:15: Mobile artifact viewers register as navigation modals, so every Back delivery mechanism returns the operator to the artifact gallery before changing dashboard views. -->
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).
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.

View File

@@ -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<ArtifactCategoryFilter>("all");
const [viewer, setViewer] = useState<ViewerState | null>(null);
const viewerReturnFocusRef = useRef<HTMLElement | null>(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" && (
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={closeViewer} onOpenTask={onOpenTask} />
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={dismissViewer} onOpenTask={onOpenTask} />
)}
{viewer && viewer.kind === "pdf" && (
<PdfViewer artifact={viewer.artifact} projectId={projectId} t={t} onClose={closeViewer} onOpenTask={onOpenTask} />
<PdfViewer artifact={viewer.artifact} projectId={projectId} t={t} onClose={dismissViewer} onOpenTask={onOpenTask} />
)}
{viewer && viewer.kind === "doc" && (
<DocViewer
@@ -206,7 +224,7 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO
projectId={projectId}
t={t}
addToast={addToast}
onClose={closeViewer}
onClose={dismissViewer}
onOpenTask={onOpenTask}
onArtifactUpdated={onArtifactUpdated}
/>

View File

@@ -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 }) => <div>{children}</div>,
}));
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 <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
}
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(
<HistoryHarness onReady={(history) => { navigationHistory = history; }}>
<ArtifactsGallery {...galleryProps} isMobile={isMobile} />
</HistoryHarness>,
);
}
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(<ArtifactsGallery {...galleryProps} />);
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();
});
});