feat(FN-2269): migrate dashboard session selection to global settings

- Add global settings schema/types fields for persisted dashboard session state (selected project and node)
- Refactor NodeContext and current-project hooks to read/write project and node selection via global settings instead of project-local state
- Update App wiring to use the new selection flow across dashboard startup and switching behavior
- Add and expand dashboard tests for NodeContext, useCurrentProject, and view-state persistence behavior
This commit is contained in:
Fusion
2026-04-22 17:12:27 -07:00
committed by gsxdsm
parent 331d283125
commit 85a1adbd0b
9 changed files with 602 additions and 118 deletions

View File

@@ -64,19 +64,36 @@ function AppInner() {
// Project management hooks - MUST be called before any conditional logic
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects();
const { nodes } = useNodes();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// Node context for local/remote node switching - must be called before useCurrentProject
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
// Current project with node-aware persistence
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects, { nodeId: currentNodeId });
const {
hasAiProvider,
hasGithub,
loading: setupReadinessLoading,
hasWarnings,
} = useSetupReadiness(currentProject?.id);
// Node context for local/remote node switching
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
// Sync node context with useNodes() results - fall back to local if selected node is missing
// Sync node context with useNodes() results:
// - Resolve saved node ID to full NodeConfig when nodes list loads
// - Fall back to local if selected node is missing or deleted
useEffect(() => {
// If we have a saved node ID but no currentNode yet (initial hydration),
// resolve it from the nodes list
if (currentNodeId && !currentNode && nodes.length > 0) {
const foundNode = nodes.find((n) => n.id === currentNodeId);
if (foundNode) {
setCurrentNode(foundNode);
return;
}
}
// If we have a currentNode but the saved ID no longer exists in nodes list,
// fall back to local view
if (currentNodeId && nodes.length > 0) {
const nodeExists = nodes.some((n) => n.id === currentNodeId);
if (!nodeExists) {
@@ -84,7 +101,7 @@ function AppInner() {
clearCurrentNode();
}
}
}, [currentNodeId, nodes, clearCurrentNode]);
}, [currentNodeId, currentNode, nodes, setCurrentNode, clearCurrentNode]);
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");

View File

@@ -1,12 +1,15 @@
/**
* NodeContext provides React context for tracking which node the dashboard is currently viewing.
* This enables seamless routing of API calls through the proxy when viewing remote nodes.
* Persists the selected node ID to global settings (server-backed) instead of localStorage.
*/
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import type { NodeConfig } from "@fusion/core";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
const STORAGE_KEY = "fusion-dashboard-current-node";
// Legacy localStorage key for migration - no longer used as primary storage
const LEGACY_STORAGE_KEY = "fusion-dashboard-current-node";
export interface NodeContextValue {
/** Currently selected node or null if viewing local node */
@@ -29,43 +32,93 @@ export interface NodeProviderProps {
/**
* Provider component that manages the current node state.
* Persists the selected nodeId to localStorage and derives isRemote from node type.
* Persists the selected nodeId to global settings and derives isRemote from node type.
* This enables PWA fresh sessions to restore the correct node context.
*/
export function NodeProvider({ children }: NodeProviderProps) {
const [currentNode, setCurrentNodeState] = useState<NodeConfig | null>(null);
const [loading, setLoading] = useState(true);
// Track if we've hydrated from global settings
const hydratedRef = useRef(false);
// Cache of current settings to avoid repeated fetches
const settingsCacheRef = useRef<string | undefined>(undefined);
// Load from localStorage on mount
// Load from global settings on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as NodeConfig;
// Only restore if it's a remote node
if (parsed && parsed.type === "remote") {
setCurrentNodeState(parsed);
let cancelled = false;
async function loadFromGlobalSettings() {
try {
const settings = await fetchGlobalSettings();
if (cancelled) return;
// Cache the current node ID
settingsCacheRef.current = settings.dashboardCurrentNodeId;
const savedNodeId = settings.dashboardCurrentNodeId;
if (savedNodeId) {
// We don't have the full NodeConfig from settings, just the ID
// The App.tsx will resolve the full node from the nodes list
// We just need to indicate that a remote node is selected
hydratedRef.current = true;
}
// Also migrate legacy localStorage if no global settings entry exists
if (!savedNodeId) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
const parsed = JSON.parse(legacy) as NodeConfig;
if (parsed?.id && parsed?.type === "remote") {
// Migrate to global settings
settingsCacheRef.current = parsed.id;
await updateGlobalSettings({ dashboardCurrentNodeId: parsed.id }).catch(() => {
// Non-critical - migration failed
});
}
}
} catch {
// Ignore legacy localStorage errors
}
}
} catch {
// Global settings fetch failed - non-critical
} finally {
if (!cancelled) {
setLoading(false);
}
}
} catch {
// Ignore localStorage errors
}
loadFromGlobalSettings();
return () => {
cancelled = true;
};
}, []);
// Persist remote node to localStorage
// Sync remote node selection to global settings
useEffect(() => {
if (currentNode && currentNode.type === "remote") {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentNode));
} catch {
// Ignore localStorage errors
}
} else {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore localStorage errors
if (loading) return;
// Only persist if we've hydrated and have a remote node
if (currentNode && currentNode.type === "remote" && currentNode.id) {
const newId = currentNode.id;
if (settingsCacheRef.current !== newId) {
settingsCacheRef.current = newId;
updateGlobalSettings({ dashboardCurrentNodeId: newId }).catch(() => {
// Non-critical - persistence failed
});
}
} else if (currentNode === null && settingsCacheRef.current !== undefined) {
// Clear the node selection
settingsCacheRef.current = undefined;
updateGlobalSettings({ dashboardCurrentNodeId: undefined }).catch(() => {
// Non-critical - persistence failed
});
}
}, [currentNode]);
}, [currentNode, loading]);
const setCurrentNode = useCallback((node: NodeConfig | null) => {
setCurrentNodeState(node);
@@ -73,11 +126,11 @@ export function NodeProvider({ children }: NodeProviderProps) {
const clearCurrentNode = useCallback(() => {
setCurrentNodeState(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore localStorage errors
}
// Clear from cache immediately for responsiveness
settingsCacheRef.current = undefined;
updateGlobalSettings({ dashboardCurrentNodeId: undefined }).catch(() => {
// Non-critical - persistence failed
});
}, []);
const value: NodeContextValue = {

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { NodeProvider, useNodeContext } from "../NodeContext";
import type { NodeConfig } from "@fusion/core";
// Mock the API functions
vi.mock("../../api", () => ({
fetchGlobalSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
const mockRemoteNode: NodeConfig = {
id: "node-remote-1",
name: "Remote Node",
type: "remote",
url: "https://remote.example.com",
apiKey: "test-key",
};
describe("NodeContext", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// Default mock implementations
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
});
afterEach(() => {
localStorage.clear();
});
function renderWithProvider() {
return renderHook(() => useNodeContext(), {
wrapper: ({ children }) => <NodeProvider>{children}</NodeProvider>,
});
}
it("initializes with null currentNode when no saved node", async () => {
const { result } = renderWithProvider();
await waitFor(() => {
expect(result.current.currentNode).toBeNull();
});
expect(result.current.currentNodeId).toBeNull();
expect(result.current.isRemote).toBe(false);
});
it("loads saved node ID from global settings", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentNodeId: "node-remote-1",
});
const { result } = renderWithProvider();
// Should have loaded the node ID from global settings
// Note: currentNode stays null until App.tsx resolves it from nodes list
await waitFor(() => {
expect(fetchGlobalSettings).toHaveBeenCalled();
});
// The NodeContext only stores the ID; App.tsx resolves the full node
// We verify the settings were fetched correctly
expect(result.current.currentNode).toBeNull();
});
it("persists node selection to global settings", async () => {
const { result } = renderWithProvider();
await waitFor(() => {
expect(result.current.currentNode).toBeNull();
});
act(() => {
result.current.setCurrentNode(mockRemoteNode);
});
expect(result.current.currentNode).toEqual(mockRemoteNode);
expect(result.current.currentNodeId).toBe("node-remote-1");
expect(result.current.isRemote).toBe(true);
// Should persist to global settings
expect(updateGlobalSettings).toHaveBeenCalledWith({
dashboardCurrentNodeId: "node-remote-1",
});
});
it("clears node selection and updates global settings", async () => {
// Start with a saved node
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentNodeId: "node-remote-1",
});
const { result } = renderWithProvider();
await waitFor(() => {
expect(fetchGlobalSettings).toHaveBeenCalled();
});
// Simulate App.tsx resolving the node from nodes list
act(() => {
result.current.setCurrentNode(mockRemoteNode);
});
expect(result.current.currentNode).toEqual(mockRemoteNode);
// Clear the selection
act(() => {
result.current.clearCurrentNode();
});
expect(result.current.currentNode).toBeNull();
expect(result.current.currentNodeId).toBeNull();
expect(result.current.isRemote).toBe(false);
// Should persist the clear to global settings
expect(updateGlobalSettings).toHaveBeenLastCalledWith({
dashboardCurrentNodeId: undefined,
});
});
it("handles global settings fetch failure gracefully", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
const { result } = renderWithProvider();
await waitFor(() => {
expect(result.current.currentNode).toBeNull();
});
// Should still be usable even when settings fetch fails
act(() => {
result.current.setCurrentNode(mockRemoteNode);
});
expect(result.current.currentNode).toEqual(mockRemoteNode);
expect(result.current.isRemote).toBe(true);
});
it("handles global settings update failure gracefully", async () => {
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
const { result } = renderWithProvider();
await waitFor(() => {
expect(result.current.currentNode).toBeNull();
});
// Should still update state even if persistence fails
act(() => {
result.current.setCurrentNode(mockRemoteNode);
});
expect(result.current.currentNode).toEqual(mockRemoteNode);
});
it("migrates legacy localStorage to global settings", async () => {
// Set up legacy localStorage
localStorage.setItem("fusion-dashboard-current-node", JSON.stringify(mockRemoteNode));
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
const { result } = renderWithProvider();
await waitFor(() => {
expect(fetchGlobalSettings).toHaveBeenCalled();
});
// Should migrate to global settings
expect(updateGlobalSettings).toHaveBeenCalledWith({
dashboardCurrentNodeId: "node-remote-1",
});
});
it("does not migrate local node from legacy localStorage", async () => {
// localStorage stores remote nodes only; local is represented by null
const { result } = renderWithProvider();
await waitFor(() => {
expect(fetchGlobalSettings).toHaveBeenCalled();
});
// Should not persist any changes when on local node
act(() => {
result.current.clearCurrentNode();
});
// Should clear from global settings
expect(updateGlobalSettings).toHaveBeenCalledWith({
dashboardCurrentNodeId: undefined,
});
});
it("throws error when useNodeContext is used outside NodeProvider", () => {
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
renderHook(() => useNodeContext());
}).toThrow("useNodeContext must be used within a NodeProvider");
consoleSpy.mockRestore();
});
});

View File

@@ -165,6 +165,30 @@ describe("useViewState", () => {
vi.useRealTimers();
});
it("does NOT call openSetupWizard when projects exist even if no current project selected", async () => {
vi.useFakeTimers();
const openSetupWizard = vi.fn();
renderHook(() =>
useViewState(
createOptions({
projectsLength: 3, // Projects exist
currentProject: null, // But none selected yet
openSetupWizard,
}),
),
);
await act(async () => {
vi.advanceTimersByTime(500);
});
// Should NOT open setup wizard when projects already exist
// The dashboard should show overview mode to let user pick a project
expect(openSetupWizard).not.toHaveBeenCalled();
vi.useRealTimers();
});
// ── Insights view persistence ─────────────────────────────────────
it("reads saved insights taskView from scoped localStorage on init", async () => {

View File

@@ -3,6 +3,14 @@ import { renderHook, waitFor, act } from "@testing-library/react";
import { useCurrentProject } from "./useCurrentProject";
import type { ProjectInfo } from "../api";
// Mock the API functions
vi.mock("../api", () => ({
fetchGlobalSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
describe("useCurrentProject", () => {
const mockProjects: ProjectInfo[] = [
{
@@ -28,6 +36,9 @@ describe("useCurrentProject", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// Default mock implementations
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
});
afterEach(() => {
@@ -57,8 +68,10 @@ describe("useCurrentProject", () => {
});
});
it("loads saved project from localStorage", async () => {
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[0]));
it("loads saved project from global settings", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects));
@@ -66,9 +79,26 @@ describe("useCurrentProject", () => {
expect(result.current.loading).toBe(false);
});
// After validation, it should have the saved project
// After hydration, it should have the saved project
await waitFor(() => {
expect(result.current.currentProject).not.toBeNull();
expect(result.current.currentProject?.id).toBe("proj_1");
});
});
it("loads saved project for specific node ID", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { "node-123": "proj_2" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects, { nodeId: "node-123" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should load proj_2 for node-123
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_2");
});
});
@@ -85,18 +115,10 @@ describe("useCurrentProject", () => {
});
});
it("clears selection when project no longer exists", async () => {
const unregisteredProject: ProjectInfo = {
id: "proj_old",
name: "Old Project",
path: "/old/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(unregisteredProject));
it("clears selection when project no longer exists and defaults to first active", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { local: "proj_old" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects));
@@ -108,9 +130,16 @@ describe("useCurrentProject", () => {
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_1");
});
// Should persist the new selection
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
}),
);
});
it("setCurrentProject updates selection and saves to localStorage", async () => {
it("setCurrentProject updates selection and persists to global settings", async () => {
const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => {
@@ -122,11 +151,35 @@ describe("useCurrentProject", () => {
});
expect(result.current.currentProject?.id).toBe("proj_2");
expect(localStorage.getItem("kb-dashboard-current-project")).toContain("proj_2");
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { local: "proj_2" },
}),
);
});
it("clearCurrentProject removes selection and re-defaults when projects available", async () => {
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[1]));
it("setCurrentProject uses node ID as key when provided", async () => {
const { result } = renderHook(() => useCurrentProject(mockProjects, { nodeId: "node-456" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.setCurrentProject(mockProjects[1]);
});
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { "node-456": "proj_2" },
}),
);
});
it("clearCurrentProject removes selection and does not auto-select", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
});
const { result } = renderHook(() => useCurrentProject(mockProjects));
@@ -134,9 +187,9 @@ describe("useCurrentProject", () => {
expect(result.current.loading).toBe(false);
});
// After loading, we should have proj_2 from localStorage
// After loading, we should have proj_1
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_2");
expect(result.current.currentProject?.id).toBe("proj_1");
});
act(() => {
@@ -148,17 +201,32 @@ describe("useCurrentProject", () => {
expect(result.current.currentProject).toBeNull();
});
// localStorage should be cleared
expect(localStorage.getItem("kb-dashboard-current-project")).toBeNull();
// Should persist the clear (remove the key)
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: {},
}),
);
});
it("handles localStorage errors gracefully", async () => {
// Mock localStorage to throw
const originalSetItem = localStorage.setItem;
localStorage.setItem = vi.fn(() => {
throw new Error("Storage error");
it("handles global settings fetch failure gracefully", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should fall back to default behavior (first active project)
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_1");
});
});
it("handles global settings update failure gracefully", async () => {
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => {
@@ -166,13 +234,34 @@ describe("useCurrentProject", () => {
});
act(() => {
result.current.setCurrentProject(mockProjects[0]);
result.current.setCurrentProject(mockProjects[1]);
});
// Should still update state even if localStorage fails
expect(result.current.currentProject?.id).toBe("proj_1");
// Should still update state even if persistence fails
expect(result.current.currentProject?.id).toBe("proj_2");
});
// Restore
localStorage.setItem = originalSetItem;
it("migrates legacy localStorage to global settings", async () => {
// Set up legacy localStorage
localStorage.setItem("kb-dashboard-current-project", JSON.stringify(mockProjects[0]));
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({});
const { result } = renderHook(() => useCurrentProject(mockProjects));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should load from legacy localStorage
await waitFor(() => {
expect(result.current.currentProject?.id).toBe("proj_1");
});
// Should migrate to global settings
expect(updateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
dashboardCurrentProjectIdByNode: { local: "proj_1" },
}),
);
});
});

View File

@@ -1,7 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { ProjectInfo } from "../api";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
const STORAGE_KEY = "kb-dashboard-current-project";
// Legacy localStorage key for migration - no longer used as primary storage
const LEGACY_STORAGE_KEY = "kb-dashboard-current-project";
/**
* Get the node key used in dashboardCurrentProjectIdByNode.
* Use "local" for the local node, otherwise use the node ID.
*/
function getNodeKey(nodeId: string | null): string {
return nodeId ?? "local";
}
export interface UseCurrentProjectResult {
/** Currently selected project or null if none selected */
@@ -10,37 +20,106 @@ export interface UseCurrentProjectResult {
setCurrentProject: (project: ProjectInfo | null) => void;
/** Clear the current project selection (suppresses auto-select) */
clearCurrentProject: () => void;
/** Whether we're still loading from localStorage */
/** Whether we're still loading from global settings */
loading: boolean;
}
interface UseCurrentProjectOptions {
/** Node ID from NodeContext - used to key project selection per node */
nodeId?: string | null;
}
/**
* Hook for managing the currently selected project.
* Persists selection to localStorage and validates the project still exists.
* Persists selection to global settings (server-backed) instead of localStorage.
* This enables PWA fresh sessions to restore the correct project context.
*/
export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentProjectResult {
export function useCurrentProject(
availableProjects: ProjectInfo[],
options: UseCurrentProjectOptions = {},
): UseCurrentProjectResult {
const { nodeId = null } = options;
const [currentProject, setCurrentProjectState] = useState<ProjectInfo | null>(null);
const [loading, setLoading] = useState(true);
// Track if we've hydrated from global settings (vs just initialized)
const hydratedRef = useRef(false);
// When true, the user explicitly cleared the project (e.g. clicked "Projects")
// and we should not auto-select until they pick one manually.
const explicitlyClearedRef = useRef(false);
// Cache of current settings to avoid repeated fetches
const settingsCacheRef = useRef<Record<string, string> | null>(null);
// Load from localStorage on mount
const nodeKey = getNodeKey(nodeId);
// Load from global settings on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as ProjectInfo;
setCurrentProjectState(parsed);
}
} catch {
// Ignore localStorage errors
} finally {
setLoading(false);
}
}, []);
let cancelled = false;
// Validate project still exists and persist to localStorage
async function loadFromGlobalSettings() {
try {
const settings = await fetchGlobalSettings();
if (cancelled) return;
// Build cache from settings
settingsCacheRef.current = settings.dashboardCurrentProjectIdByNode ?? {};
const savedProjectId = settingsCacheRef.current[nodeKey];
if (savedProjectId) {
// Try to find the saved project in available projects
const found = availableProjects.find((p) => p.id === savedProjectId);
if (found) {
setCurrentProjectState(found);
hydratedRef.current = true;
}
// If project not found, we'll handle in the next effect
// (project may still be loading or was unregistered)
}
// Also migrate legacy localStorage if no global settings entry exists
if (!savedProjectId) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
const parsed = JSON.parse(legacy) as ProjectInfo;
if (parsed?.id) {
// Check if project still exists
const exists = availableProjects.some((p) => p.id === parsed.id);
if (exists) {
setCurrentProjectState(parsed);
hydratedRef.current = true;
// Migrate to global settings
settingsCacheRef.current = { ...settingsCacheRef.current, [nodeKey]: parsed.id };
await updateGlobalSettings({
dashboardCurrentProjectIdByNode: settingsCacheRef.current,
}).catch(() => {
// Non-critical - migration failed, but we have the data in memory
});
}
}
}
} catch {
// Ignore legacy localStorage errors
}
}
} catch {
// Global settings fetch failed - this is non-critical
// We'll fall back to default behavior
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadFromGlobalSettings();
return () => {
cancelled = true;
};
}, [nodeKey, availableProjects]);
// Validate project still exists and persist to global settings
useEffect(() => {
if (loading) return;
@@ -54,12 +133,12 @@ export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentP
return;
}
// Persist to localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentProject));
} catch {
// Ignore localStorage errors
}
// Persist to global settings
const newCache = { ...settingsCacheRef.current, [nodeKey]: currentProject.id };
settingsCacheRef.current = newCache;
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
// Non-critical - persistence failed
});
} else if (availableProjects.length > 0 && !explicitlyClearedRef.current) {
// No selection but projects available - default to first active
// Skip if user explicitly cleared (navigated to overview)
@@ -68,35 +147,36 @@ export function useCurrentProject(availableProjects: ProjectInfo[]): UseCurrentP
setCurrentProjectState(firstActive);
}
}
}, [currentProject, availableProjects, loading]);
}, [currentProject, availableProjects, loading, nodeKey]);
const setCurrentProject = useCallback((project: ProjectInfo | null) => {
explicitlyClearedRef.current = false;
setCurrentProjectState(project);
if (project) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
} catch {
// Ignore localStorage errors
const setCurrentProject = useCallback(
(project: ProjectInfo | null) => {
explicitlyClearedRef.current = false;
setCurrentProjectState(project);
if (project) {
const newCache = { ...settingsCacheRef.current, [nodeKey]: project.id };
settingsCacheRef.current = newCache;
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
// Non-critical - persistence failed
});
}
} else {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore localStorage errors
}
}
}, []);
},
[nodeKey],
);
const clearCurrentProject = useCallback(() => {
explicitlyClearedRef.current = true;
setCurrentProjectState(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore localStorage errors
}
}, []);
// Remove from cache and persist
const newCache = { ...settingsCacheRef.current };
delete newCache[nodeKey];
settingsCacheRef.current = newCache;
updateGlobalSettings({ dashboardCurrentProjectIdByNode: newCache }).catch(() => {
// Non-critical - persistence failed
});
}, [nodeKey]);
return {
currentProject,