From 3453d164eac908860cb71acd98c4c9dec0b3f06c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 07:48:26 -0700 Subject: [PATCH] FN-7416: preserve dashboard project selection in URL Keep dashboard project selection in the URL so refreshing or sharing the page retains the active project. - Hydrate current project state from the existing ?project= query parameter before falling back to cached or global defaults. - Update project selection, setup completion, overview, and unregister flows to preserve or clear the project URL parameter without dropping other URL state. - Cover URL-driven project persistence and query/hash preservation with hook tests. - Document the refresh-safe project URL behavior and add a patch changeset. Files changed: .changeset/fn-7416-project-url-persistence.md | 7 +++ docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 2 +- .../app/hooks/__tests__/useCurrentProject.test.ts | 64 ++++++++++++++++++++++ .../app/hooks/__tests__/useProjectActions.test.ts | 40 +++++++++++++- packages/dashboard/app/hooks/useCurrentProject.ts | 54 ++++++++++++++++-- packages/dashboard/app/hooks/useProjectActions.ts | 5 ++ packages/dashboard/app/utils/projectUrlState.ts | 24 ++++++++ 8 files changed, 190 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7416 Fusion-Task-Lineage: 2d640ae6-629f-4632-9014-986320acfef5 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7416-project-url-persistence.md | 7 ++ docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 2 +- .../hooks/__tests__/useCurrentProject.test.ts | 64 +++++++++++++++++++ .../hooks/__tests__/useProjectActions.test.ts | 40 +++++++++++- .../dashboard/app/hooks/useCurrentProject.ts | 54 ++++++++++++++-- .../dashboard/app/hooks/useProjectActions.ts | 5 ++ .../dashboard/app/utils/projectUrlState.ts | 24 +++++++ 8 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 .changeset/fn-7416-project-url-persistence.md create mode 100644 packages/dashboard/app/utils/projectUrlState.ts diff --git a/.changeset/fn-7416-project-url-persistence.md b/.changeset/fn-7416-project-url-persistence.md new file mode 100644 index 0000000000..820b04459f --- /dev/null +++ b/.changeset/fn-7416-project-url-persistence.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve the selected dashboard project across browser refreshes. +category: fix +dev: Project selection now updates and hydrates from the existing `?project=` dashboard URL contract. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a2dc27a16d..c14c313021 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -98,6 +98,8 @@ Use deep links to open a specific task directly from notifications, chat, or ext - `/tasks/` (for example, `/tasks/FN-1234`) opens that task, and can include `?project=` for multi-project routing. - `/?task=[&project=]` is the canonical in-app form and opens the task detail modal on load. +- Selecting a project from the dashboard project switcher writes `?project=` into the URL and preserves unrelated query parameters/hash fragments, so refreshing the browser keeps the same selected project instead of returning to the default project. + - Legacy path-style links (including trailing-slash forms like `/tasks//` and older hash-style entry points that resolve to that path) are normalized client-side to the canonical query form with `history.replaceState`, so the URL updates without a full reload. - In non-headless dashboard mode, the server also issues an HTTP 301 redirect from `/tasks/` to `/?task=` and preserves `?project=` when present. - Theme assets resolve `theme-data.css` against the current document base (HTTP/HTTPS, `file://`, and Electron fallback paths), so non-default themes still load correctly when you land on deep-linked or sub-path URLs. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index eff31fa2fc..b640d9dd21 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -230,7 +230,7 @@ function AppInner() { const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext(); // Current project with node-aware persistence - const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects, { nodeId: currentNodeId }); + const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects, { nodeId: currentNodeId, projectsLoading }); const { hasAiProvider, diff --git a/packages/dashboard/app/hooks/__tests__/useCurrentProject.test.ts b/packages/dashboard/app/hooks/__tests__/useCurrentProject.test.ts index 2c8d45f46a..2b9e33f1c4 100644 --- a/packages/dashboard/app/hooks/__tests__/useCurrentProject.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useCurrentProject.test.ts @@ -44,6 +44,7 @@ describe("useCurrentProject", () => { beforeEach(() => { localStorage.clear(); + window.history.replaceState({}, "", "/"); vi.clearAllMocks(); mockReadCache.mockReturnValue(null); // Default mock implementations @@ -104,6 +105,69 @@ describe("useCurrentProject", () => { ); }); + it("hydrates from URL project before settings/cache fallback", async () => { + mockReadCache.mockReturnValueOnce("proj_1"); + window.history.replaceState({}, "", "/?project=proj_2"); + (fetchGlobalSettings as ReturnType).mockResolvedValue({ + dashboardCurrentProjectIdByNode: { local: "proj_1" }, + }); + + const { result } = renderHook(() => useCurrentProject(mockProjects)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentProject?.id).toBe("proj_2"); + expect(fetchGlobalSettings).not.toHaveBeenCalled(); + expect(mockWriteCache).toHaveBeenCalledWith(swrCache.SWR_CACHE_KEYS.CURRENT_PROJECT_ID, "proj_2"); + }); + + it("waits for URL project while projects are still loading", async () => { + window.history.replaceState({}, "", "/?project=proj_2"); + + const { result, rerender } = renderHook( + ({ projects, projectsLoading }) => useCurrentProject(projects, { projectsLoading }), + { initialProps: { projects: [] as ProjectInfo[], projectsLoading: true } }, + ); + + expect(result.current.currentProject).toBeNull(); + expect(result.current.loading).toBe(true); + + rerender({ projects: cloneProjects(), projectsLoading: false }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.currentProject?.id).toBe("proj_2"); + expect(fetchGlobalSettings).not.toHaveBeenCalled(); + }); + + it("keeps unknown URL project from falling back to default project", async () => { + window.history.replaceState({}, "", "/?project=missing"); + (fetchGlobalSettings as ReturnType).mockResolvedValue({ + dashboardCurrentProjectIdByNode: { local: "proj_1" }, + }); + + const { result, rerender } = renderHook( + ({ projects }) => useCurrentProject(projects), + { initialProps: { projects: cloneProjects() } }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentProject).toBeNull(); + expect(fetchGlobalSettings).not.toHaveBeenCalled(); + + for (let i = 0; i <= CONSECUTIVE_ABSENCE_THRESHOLD; i += 1) { + rerender({ projects: cloneProjects() }); + } + + expect(result.current.currentProject).toBeNull(); + }); + it("falls back to settings-driven selection when cache miss occurs", async () => { mockReadCache.mockReturnValueOnce(null); (fetchGlobalSettings as ReturnType).mockResolvedValue({ diff --git a/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts b/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts index d46daf8cba..a7e2b82459 100644 --- a/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts @@ -47,13 +47,15 @@ function createOptions(overrides: Partial[0 describe("useProjectActions", () => { beforeEach(() => { vi.clearAllMocks(); + window.history.replaceState({ preserved: true }, "", "/"); mockPauseProject.mockResolvedValue(PROJECT); mockResumeProject.mockResolvedValue(PROJECT); mockUpdateProject.mockResolvedValue(PROJECT); mockUnregisterProject.mockResolvedValue(undefined); }); - it("handleSelectProject sets current project and view mode", () => { + it("handleSelectProject sets current project, view mode, and URL project state", () => { + window.history.replaceState({ preserved: "state" }, "", "/?task=FN-1&view=mailbox#message-1"); const options = createOptions(); const { result } = renderHook(() => useProjectActions(options)); @@ -63,9 +65,40 @@ describe("useProjectActions", () => { expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT); expect(options.setViewMode).toHaveBeenCalledWith("project"); + expect(window.location.pathname).toBe("/"); + expect(window.location.search).toBe("?task=FN-1&view=mailbox&project=proj_123"); + expect(window.location.hash).toBe("#message-1"); + expect(window.history.state).toEqual({ preserved: "state" }); }); - it("handleViewAllProjects clears current project and sets overview", () => { + it("handleSelectProject URL-encodes project ids", () => { + const encodedProject: ProjectInfo = { ...PROJECT, id: "blendance/system id" }; + const options = createOptions(); + const { result } = renderHook(() => useProjectActions(options)); + + act(() => { + result.current.handleSelectProject(encodedProject); + }); + + expect(window.location.search).toBe("?project=blendance%2Fsystem+id"); + expect(new URLSearchParams(window.location.search).get("project")).toBe("blendance/system id"); + }); + + it("handleSelectProject writes project ids rather than duplicate display names", () => { + const duplicateNameProject: ProjectInfo = { ...PROJECT, id: "proj_unique_b", name: "Duplicate" }; + const options = createOptions(); + const { result } = renderHook(() => useProjectActions(options)); + + act(() => { + result.current.handleSelectProject(duplicateNameProject); + }); + + expect(new URLSearchParams(window.location.search).get("project")).toBe("proj_unique_b"); + expect(window.location.search).not.toContain("Duplicate"); + }); + + it("handleViewAllProjects clears current project, sets overview, and removes only URL project state", () => { + window.history.replaceState({ preserved: "state" }, "", "/?project=proj_123&task=FN-1&room=room-1#thread"); const options = createOptions(); const { result } = renderHook(() => useProjectActions(options)); @@ -75,6 +108,9 @@ describe("useProjectActions", () => { expect(options.clearCurrentProject).toHaveBeenCalledTimes(1); expect(options.setViewMode).toHaveBeenCalledWith("overview"); + expect(window.location.search).toBe("?task=FN-1&room=room-1"); + expect(window.location.hash).toBe("#thread"); + expect(window.history.state).toEqual({ preserved: "state" }); }); it("handleSetupComplete closes wizard, sets project, toasts, and refreshes", async () => { diff --git a/packages/dashboard/app/hooks/useCurrentProject.ts b/packages/dashboard/app/hooks/useCurrentProject.ts index 2817439fc1..44c1828000 100644 --- a/packages/dashboard/app/hooks/useCurrentProject.ts +++ b/packages/dashboard/app/hooks/useCurrentProject.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { ProjectInfo } from "../api"; import { fetchGlobalSettings, updateGlobalSettings } from "../api"; +import { getProjectIdFromUrl } from "../utils/projectUrlState"; import { readCache, SWR_CACHE_KEYS, SWR_LONG_MAX_AGE_MS, writeCache } from "../utils/swrCache"; // Legacy localStorage key for migration - no longer used as primary storage @@ -20,21 +21,32 @@ export interface UseCurrentProjectResult { interface UseCurrentProjectOptions { nodeId?: string | null; + projectsLoading?: boolean; } export function useCurrentProject( availableProjects: ProjectInfo[], options: UseCurrentProjectOptions = {}, ): UseCurrentProjectResult { - const { nodeId = null } = options; + const { nodeId = null, projectsLoading = false } = options; + const urlProjectId = getProjectIdFromUrl(); + const urlProject = urlProjectId + ? availableProjects.find((project) => project.id === urlProjectId) ?? null + : null; const cachedProjectId = readCache(SWR_CACHE_KEYS.CURRENT_PROJECT_ID, { maxAgeMs: SWR_LONG_MAX_AGE_MS }); const cachedProject = - typeof cachedProjectId === "string" && cachedProjectId.length > 0 + urlProject ?? + (typeof cachedProjectId === "string" && cachedProjectId.length > 0 ? availableProjects.find((project) => project.id === cachedProjectId) ?? null - : null; + : null); const [currentProject, setCurrentProjectState] = useState(cachedProject); - const [loading, setLoading] = useState(() => cachedProject === null); + const [loading, setLoading] = useState(() => { + if (urlProjectId) { + return projectsLoading || (availableProjects.length === 0 && !urlProject); + } + return cachedProject === null; + }); const hydratedRef = useRef(false); const hydratedNodeKeyRef = useRef(null); const explicitlyClearedRef = useRef(false); @@ -77,6 +89,33 @@ export function useCurrentProject( }; } + if (urlProjectId) { + const foundFromUrl = availableProjects.find((project) => project.id === urlProjectId) ?? null; + if (foundFromUrl) { + explicitlyClearedRef.current = false; + absentCountRef.current = 0; + autoDefaultCountRef.current = 0; + setCurrentProjectState(foundFromUrl); + persistCurrentProjectId(foundFromUrl.id); + hydratedRef.current = true; + hydratedNodeKeyRef.current = nodeKey; + setLoading(false); + } else if (!projectsLoading) { + explicitlyClearedRef.current = true; + absentCountRef.current = 0; + autoDefaultCountRef.current = 0; + setCurrentProjectState(null); + hydratedRef.current = true; + hydratedNodeKeyRef.current = nodeKey; + setLoading(false); + } else { + setLoading(true); + } + return () => { + cancelled = true; + }; + } + const cacheResolvesToKnownProject = typeof cachedProjectId === "string" && cachedProjectId.length > 0 && @@ -144,7 +183,7 @@ export function useCurrentProject( return () => { cancelled = true; }; - }, [availableProjects, cachedProjectId, nodeKey, persistCurrentProjectId]); + }, [availableProjects, cachedProjectId, nodeKey, persistCurrentProjectId, projectsLoading, urlProjectId]); useEffect(() => { absentCountRef.current = 0; @@ -154,6 +193,10 @@ export function useCurrentProject( useEffect(() => { if (loading) return; + if (urlProjectId && !currentProject) { + return; + } + if (currentProject) { autoDefaultCountRef.current = 0; const stillExists = availableProjects.some((p) => p.id === currentProject.id); @@ -208,6 +251,7 @@ export function useCurrentProject( currentProject, loading, nodeKey, + urlProjectId, persistCurrentProjectId, pickFallbackProject, setListDrivenSelection, diff --git a/packages/dashboard/app/hooks/useProjectActions.ts b/packages/dashboard/app/hooks/useProjectActions.ts index bed12ac12c..a066aa8e24 100644 --- a/packages/dashboard/app/hooks/useProjectActions.ts +++ b/packages/dashboard/app/hooks/useProjectActions.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { pauseProject, resumeProject, unregisterProject } from "../api"; import type { ProjectInfo } from "../api"; +import { replaceProjectIdInUrl } from "../utils/projectUrlState"; import type { ViewMode } from "./useViewState"; import type { ToastType } from "./useToast"; @@ -52,11 +53,13 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject } = options; const handleSelectProject = useCallback((project: ProjectInfo) => { + replaceProjectIdInUrl(project.id); setCurrentProject(project); setViewMode("project"); }, [setCurrentProject, setViewMode]); const handleViewAllProjects = useCallback(() => { + replaceProjectIdInUrl(null); clearCurrentProject(); setViewMode("overview"); }, [clearCurrentProject, setViewMode]); @@ -71,6 +74,7 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject const handleSetupComplete = useCallback((project: ProjectInfo) => { closeSetupWizard(); + replaceProjectIdInUrl(project.id); setCurrentProject(project); setViewMode("project"); addToast(t("projects.setup.success", "Project {{name}} registered successfully", { name: project.name }), "success"); @@ -107,6 +111,7 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject addToast(t("projects.actions.removeSuccess", "Project {{name}} removed", { name: project.name }), "success"); if (currentProject?.id === project.id) { + replaceProjectIdInUrl(null); clearCurrentProject(); setViewMode("overview"); } diff --git a/packages/dashboard/app/utils/projectUrlState.ts b/packages/dashboard/app/utils/projectUrlState.ts new file mode 100644 index 0000000000..fead4e6017 --- /dev/null +++ b/packages/dashboard/app/utils/projectUrlState.ts @@ -0,0 +1,24 @@ +export function getProjectIdFromUrl(): string | null { + if (typeof window === "undefined") return null; + return new URL(window.location.href).searchParams.get("project"); +} + +export function replaceProjectIdInUrl(projectId: string | null): void { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + if (projectId && projectId.length > 0) { + url.searchParams.set("project", projectId); + } else { + url.searchParams.delete("project"); + } + + const query = url.searchParams.toString(); + const nextUrl = `${url.pathname}${query ? `?${query}` : ""}${url.hash}`; + const existingState = window.history.state ?? {}; + /* + * FNXC:ProjectUrlState 2026-07-02-00:00: + * Project selection must survive browser refresh through the dashboard's existing `?project=` deep-link contract. Replace only the project query param so task/view/mailbox/room/PR params, hashes, and history state remain intact across desktop and mobile selector paths. + */ + window.history.replaceState(existingState, "", nextUrl); +}