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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7416-project-url-persistence.md
Normal file
7
.changeset/fn-7416-project-url-persistence.md
Normal file
@@ -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.
|
||||
@@ -98,6 +98,8 @@ Use deep links to open a specific task directly from notifications, chat, or ext
|
||||
|
||||
- `/tasks/<TASK_ID>` (for example, `/tasks/FN-1234`) opens that task, and can include `?project=<project-id>` for multi-project routing.
|
||||
- `/?task=<TASK_ID>[&project=<project-id>]` is the canonical in-app form and opens the task detail modal on load.
|
||||
- Selecting a project from the dashboard project switcher writes `?project=<project-id>` 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.
|
||||
<!-- FNXC:ProjectUrlState 2026-07-02-00:00: The project switcher now uses the same `?project=` URL contract as task deep links so non-default project selections survive browser refresh and can be shared/bookmarked. -->
|
||||
- Legacy path-style links (including trailing-slash forms like `/tasks/<TASK_ID>/` 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/<TASK_ID>` to `/?task=<TASK_ID>` 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
||||
|
||||
@@ -47,13 +47,15 @@ function createOptions(overrides: Partial<Parameters<typeof useProjectActions>[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 () => {
|
||||
|
||||
@@ -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<string>(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<ProjectInfo | null>(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<string | null>(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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
24
packages/dashboard/app/utils/projectUrlState.ts
Normal file
24
packages/dashboard/app/utils/projectUrlState.ts
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user