feat(FN-813): add project-aware ntfy deep links

- Thread real project context (projectName, projectId) into ntfy notification deep links
- Dashboard deep-link URL now honors project context with /project/:name/task/:id pattern
- Update App.tsx routing to handle project-scoped deep link navigation
- Add project info to NtfyNotifier types and wire through notification payload
- Add changeset for published @gsxdsm/fusion package
- Add tests for notifier project context and dashboard routing
This commit is contained in:
gsxdsm
2026-04-03 21:34:39 -07:00
parent ac6522c2f7
commit 2fad82ea14
7 changed files with 227 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
import type { ModelInfo, ProjectInfo } from "./api";
@@ -226,6 +226,9 @@ function AppInner() {
}, [favoriteModels, favoriteProviders, addToast]);
// Handle deep link to task on mount (with optional project context)
// Uses a ref to prevent duplicate fetches when setCurrentProject triggers
// a re-run of this effect during project switching.
const deepLinkFetchedRef = useRef(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const projectParam = params.get("project");
@@ -234,7 +237,12 @@ function AppInner() {
// If no task to load, nothing to do
if (!taskId) return;
// If project param is present, try to switch to that project first
// Wait for projects to finish loading before resolving deep links.
// Without this guard, an empty projects list during loading would
// produce a false "project not found" error toast.
if (projectsLoading) return;
// If project param is present, validate it and switch project if needed
if (projectParam) {
const matchingProject = projects.find((p) => p.id === projectParam);
if (!matchingProject) {
@@ -247,7 +255,13 @@ function AppInner() {
}
}
// After project context is resolved (or if no project param), fetch the task
// Skip if we've already fetched this task (prevents double-fetch when
// setCurrentProject causes this effect to re-run).
if (deepLinkFetchedRef.current) return;
deepLinkFetchedRef.current = true;
// Use project param as the authoritative project context for the fetch
// when present; otherwise fall back to the current/default project.
const taskProjectId = projectParam ?? currentProject?.id;
fetchTaskDetail(taskId, taskProjectId)
.then((detail) => {
@@ -256,7 +270,7 @@ function AppInner() {
.catch(() => {
addToast(`Task ${taskId} not found`, "error");
});
}, [addToast, projects, currentProject, setCurrentProject]);
}, [addToast, projects, projectsLoading, currentProject, setCurrentProject]);
// View change handlers
const handleChangeTaskView = useCallback((newView: TaskView) => {

View File

@@ -64,6 +64,7 @@ vi.mock("../../hooks/useTasks", () => ({
// Mock state holders for dynamic mocking
const mockProjectsState = {
projects: [] as any[],
loading: false,
};
const mockCurrentProjectState = {
@@ -76,7 +77,7 @@ const mockCurrentProjectState = {
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
projects: mockProjectsState.projects,
loading: false,
loading: mockProjectsState.loading,
error: null,
refresh: vi.fn(),
register: vi.fn(),
@@ -124,6 +125,7 @@ beforeEach(() => {
}));
// Reset mock states
mockProjectsState.projects = [];
mockProjectsState.loading = false;
mockCurrentProjectState.currentProject = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
mockCurrentProjectState.setCurrentProject.mockClear();
mockCurrentProjectState.clearCurrentProject.mockClear();
@@ -290,6 +292,72 @@ describe("App deep link handling", () => {
// setCurrentProject should NOT be called when no project param
expect(mockCurrentProjectState.setCurrentProject).not.toHaveBeenCalled();
});
it("waits for projects to load before resolving deep links", async () => {
// Start with projects still loading
mockProjectsState.loading = true;
mockProjectsState.projects = [];
mockCurrentProjectState.currentProject = null;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=proj_123&task=FN-001"),
});
render(<App />);
// Wait a tick to ensure no premature fetch
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Should NOT have fetched the task or shown an error while loading
expect(fetchTaskDetail).not.toHaveBeenCalled();
expect(screen.queryByText(/not found/)).toBeNull();
});
it("prevents double-fetch when project switch triggers effect re-run", async () => {
const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
mockProjectsState.projects = [project1, project2];
mockCurrentProjectState.currentProject = project1;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=proj_456&task=FN-789"),
});
render(<App />);
// Wait for the task to be fetched
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789", "proj_456");
});
// fetchTaskDetail should have been called exactly once (no double-fetch)
expect(fetchTaskDetail).toHaveBeenCalledTimes(1);
});
it("fetches task from the project param's project even when current project differs", async () => {
const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
mockProjectsState.projects = [project1, project2];
mockCurrentProjectState.currentProject = project1;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=proj_456&task=FN-001"),
});
render(<App />);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", "proj_456");
});
// Should NOT have used the current project (proj_123) for the fetch
expect(fetchTaskDetail).not.toHaveBeenCalledWith("FN-001", "proj_123");
});
});
describe("App mission wiring", () => {