diff --git a/.changeset/FN-3792-tasks-deeplink.md b/.changeset/FN-3792-tasks-deeplink.md new file mode 100644 index 000000000..0889936b0 --- /dev/null +++ b/.changeset/FN-3792-tasks-deeplink.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix /tasks/:id deep links: theme stylesheet now resolves root-absolute on sub-paths and /tasks/:id redirects/rewrites to the canonical ?task= form so the task modal opens. diff --git a/packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts b/packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts index 3a47ab807..4c2f66679 100644 --- a/packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts @@ -36,7 +36,14 @@ describe("useDeepLink", () => { beforeEach(() => { vi.clearAllMocks(); - window.history.replaceState = vi.fn(); + window.history.replaceState = vi.fn((_state, _unused, url) => { + if (typeof url === "string" && url.length > 0) { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL(url, "http://localhost:3000"), + }); + } + }) as typeof window.history.replaceState; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/"), @@ -83,7 +90,51 @@ describe("useDeepLink", () => { }); }); - it("fetches and opens task detail for task deep-link", async () => { + it("rewrites /tasks/:id path and opens detail", async () => { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL("http://localhost:3000/tasks/FN-9999"), + }); + + const { openTaskDetail } = renderUseDeepLink(); + + await waitFor(() => { + expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-9999"); + expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_123"); + expect(openTaskDetail).toHaveBeenCalledTimes(1); + }); + }); + + it("preserves project query when rewriting /tasks/:id path", async () => { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL("http://localhost:3000/tasks/FN-9999?project=proj_456"), + }); + + const { setCurrentProject } = renderUseDeepLink(); + + await waitFor(() => { + expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?project=proj_456&task=FN-9999"); + expect(setCurrentProject).toHaveBeenCalledWith(otherProject); + expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_456"); + }); + }); + + it("ignores invalid /tasks/:id path", async () => { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL("http://localhost:3000/tasks/not-a-task-id"), + }); + + renderUseDeepLink(); + + await waitFor(() => { + expect(window.history.replaceState).not.toHaveBeenCalled(); + expect(mockFetchTaskDetail).not.toHaveBeenCalled(); + }); + }); + + it("fetches and opens task detail for existing ?task deep-link without path rewrite", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), @@ -95,6 +146,8 @@ describe("useDeepLink", () => { expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); expect(openTaskDetail).toHaveBeenCalledTimes(1); }); + + expect(window.history.replaceState).not.toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-123"); }); it("switches project and uses project param for task fetch", async () => { diff --git a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts index 31c02437d..1a69957b1 100644 --- a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts @@ -649,8 +649,7 @@ describe("useTheme", () => { } }); - it("resolves theme-data.css relative to document.baseURI for HTTP paths", () => { - // Simulate a non-root HTTP path like http://localhost:3000/some/path/ + it("resolves theme-data.css to origin root for HTTP sub-paths", () => { Object.defineProperty(document, "baseURI", { value: "http://localhost:3000/some/path/", configurable: true, @@ -662,12 +661,12 @@ describe("useTheme", () => { result.current.setColorTheme("ocean"); }); - const link = document.getElementById("theme-data"); + const link = document.getElementById("theme-data") as HTMLLinkElement | null; expect(link).not.toBeNull(); - // URL resolution should work with non-root base path - expect(link?.getAttribute("href")?.endsWith("theme-data.css")).toBe(true); + const resolved = new URL(link!.href); + expect(resolved.origin).toBe("http://localhost:3000"); + expect(resolved.pathname).toBe("/theme-data.css"); - // Clean up Object.defineProperty(document, "baseURI", { value: "http://localhost:3000/", configurable: true, @@ -779,8 +778,8 @@ describe("useTheme", () => { // The link should exist and href should be updated to the correct value const link = document.getElementById("theme-data") as HTMLLinkElement; expect(link).not.toBeNull(); - // href should be updated to resolve correctly for the new baseURI - expect(link?.href).toBe("http://localhost:3000/some/nested/path/theme-data.css"); + // HTTP(S) always resolves to origin-root stylesheet path. + expect(link?.href).toBe("http://localhost:3000/theme-data.css"); // Clean up link?.remove(); @@ -1210,17 +1209,42 @@ describe("getThemeInitScript", () => { expect(script).toContain("effectiveMode"); }); - it("index.html uses correct URL replacement pattern", () => { - // Verify that the inline script in index.html uses the correct URL replacement - // pattern (handle both directory paths and filename paths) rather than buggy concatenation + it("pre-hydration script resolves theme-data path like runtime loader", () => { + const script = getThemeInitScript(); + const runScript = () => { + window.eval(script); + }; + + localStorage.setItem(COLOR_THEME_STORAGE_KEY, "ocean"); + + Object.defineProperty(document, "baseURI", { + value: "http://localhost:4040/tasks/FN-3773", + configurable: true, + }); + runScript(); + let link = document.getElementById("theme-data") as HTMLLinkElement | null; + expect(link).not.toBeNull(); + expect(new URL(link!.href).origin).toBe("http://localhost:4040"); + expect(new URL(link!.href).pathname).toBe("/theme-data.css"); + + link?.remove(); + Object.defineProperty(document, "baseURI", { + value: "file:///Users/me/Projects/kb/packages/dashboard/dist/client/index.html", + configurable: true, + }); + runScript(); + link = document.getElementById("theme-data") as HTMLLinkElement | null; + expect(link).not.toBeNull(); + expect(link!.href).toBe("file:///Users/me/Projects/kb/packages/dashboard/dist/client/theme-data.css"); + }); + + it("index.html uses HTTP root-absolute and file-relative theme URL logic", () => { const indexHtml = readFileSync(resolve(PACKAGE_ROOT, "app/index.html"), "utf8"); - // The correct pattern: check if base ends with '/' and use slice or replace accordingly - // The buggy pattern: base.substring(0, 7) + dirPath + 'theme-data.css' + expect(indexHtml).toContain("new URL('/theme-data.css', base)"); expect(indexHtml).toContain("base.endsWith('/')"); expect(indexHtml).toContain("base.slice(0, -1)"); - // Ensure the buggy pattern is NOT present expect(indexHtml).not.toContain("base.substring(0, 7)"); expect(indexHtml).not.toContain("pathMatch"); }); diff --git a/packages/dashboard/app/hooks/useDeepLink.ts b/packages/dashboard/app/hooks/useDeepLink.ts index ade6c5d99..3a230b810 100644 --- a/packages/dashboard/app/hooks/useDeepLink.ts +++ b/packages/dashboard/app/hooks/useDeepLink.ts @@ -40,10 +40,28 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult { // Prevent duplicate fetches when project switching causes the effect to re-run. const deepLinkFetchedRef = useRef(false); + // Guard against StrictMode double-effect path rewrites. + const pathRewroteRef = useRef(false); + // Track whether the currently open detail modal came from a deep-link. const deepLinkTaskIdRef = useRef(null); useEffect(() => { + if (!pathRewroteRef.current) { + const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/); + if (pathMatch) { + const taskIdFromPath = pathMatch[1]; + if (/^[A-Z]+-\d+$/.test(taskIdFromPath)) { + const params = new URLSearchParams(window.location.search); + params.set("task", taskIdFromPath); + const query = params.toString(); + const existingState = window.history.state ?? {}; + window.history.replaceState(existingState, "", query ? `/?${query}` : "/"); + pathRewroteRef.current = true; + } + } + } + const params = new URLSearchParams(window.location.search); const projectParam = params.get("project"); const taskId = params.get("task"); diff --git a/packages/dashboard/app/hooks/useTheme.ts b/packages/dashboard/app/hooks/useTheme.ts index a74609c79..1b66a123e 100644 --- a/packages/dashboard/app/hooks/useTheme.ts +++ b/packages/dashboard/app/hooks/useTheme.ts @@ -15,35 +15,28 @@ const THEME_DATA_FILENAME = "theme-data.css"; /** * Get the resolved URL for theme-data.css. * - * This function handles both HTTP/HTTPS origins and Electron file:// contexts. - * Using document.baseURI ensures the stylesheet path resolves correctly regardless - * of whether the app is served over HTTP or loaded from a file:// URL. - * - * For file:// URLs, the path is derived relative to the HTML file's directory. - * For HTTP/HTTPS URLs, the path is derived relative to the HTML file's directory - * (same as file://) to ensure correct resolution in nested deployments. + * NOTE: index.html contains an inline pre-hydration copy of this logic. + * Keep both implementations behaviorally equivalent. */ function getThemeDataUrl(): string { - // Get base URL from document.baseURI (most reliable across contexts) - // Falls back to document.location.href if baseURI is unavailable const base = document.baseURI || (typeof document.location !== "undefined" ? document.location.href : ""); if (!base) { - // Fallback to absolute path if no base available return `/${THEME_DATA_FILENAME}`; } - // Derive path relative to HTML file directory - // Handle two cases: - // 1. Base ends with "/" (directory path): replace trailing "/" with "/filename" - // 2. Base ends with filename: replace filename with "/filename" - if (base.endsWith("/")) { - // Directory path: replace trailing "/" with "/theme-data.css" - return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`; - } else { - // Filename path: replace last segment with "/theme-data.css" + if (base.startsWith("http://") || base.startsWith("https://")) { + return new URL(`/${THEME_DATA_FILENAME}`, base).toString(); + } + + if (base.startsWith("file://")) { + if (base.endsWith("/")) { + return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`; + } return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`); } + + return `/${THEME_DATA_FILENAME}`; } // Check if we're in a browser environment @@ -430,6 +423,42 @@ export function getThemeInitScript(): string { document.documentElement.setAttribute('data-theme', effectiveMode); document.documentElement.setAttribute('data-color-theme', colorTheme); document.documentElement.style.fontSize = fontScale + '%'; + if (colorTheme !== 'default') { + var base = document.baseURI || (document.location && document.location.href) || ''; + var themeDataUrl; + if (!base) { + themeDataUrl = '/theme-data.css'; + } else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) { + themeDataUrl = new URL('/theme-data.css', base).toString(); + } else if (base.indexOf('file://') === 0) { + if (base.endsWith('/')) { + themeDataUrl = base.slice(0, -1) + '/theme-data.css'; + } else { + var lastSlashIndex = base.lastIndexOf('/'); + themeDataUrl = lastSlashIndex >= 0 + ? base.slice(0, lastSlashIndex) + '/theme-data.css' + : '/theme-data.css'; + } + } else { + themeDataUrl = '/theme-data.css'; + } + + var existingLink = document.getElementById('theme-data'); + if (existingLink && existingLink.tagName === 'LINK') { + if (existingLink.href !== themeDataUrl) { + existingLink.href = themeDataUrl; + } + if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) { + document.head.appendChild(existingLink); + } + } else { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = themeDataUrl; + link.id = 'theme-data'; + document.head.appendChild(link); + } + } } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-color-theme', 'default'); diff --git a/packages/dashboard/app/index.html b/packages/dashboard/app/index.html index 37ef81ffa..f54d0bbab 100644 --- a/packages/dashboard/app/index.html +++ b/packages/dashboard/app/index.html @@ -39,28 +39,20 @@ document.documentElement.setAttribute('data-color-theme', colorTheme); document.documentElement.style.fontSize = fontScale + '%'; // Load theme-data.css for non-default themes to prevent flash - // Use path-safe resolution that works in both HTTP and file:// contexts + // This logic mirrors app/hooks/useTheme.ts#getThemeDataUrl() if (colorTheme !== 'default') { var base = document.baseURI || (document.location && document.location.href) || ''; - // Derive path relative to HTML file directory - // Handle two cases: - // 1. Base ends with "/" (directory path): replace trailing "/" with "/filename" - // 2. Base ends with filename: replace filename with "/filename" var themeDataUrl; - if (base.indexOf('file://') === 0) { - // For file:// URLs - if (base.endsWith('/')) { - themeDataUrl = base.slice(0, -1) + '/theme-data.css'; - } else { - themeDataUrl = base.replace(/\/[^\/]+$/, '/theme-data.css'); - } + if (!base) { + themeDataUrl = '/theme-data.css'; + } else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) { + themeDataUrl = new URL('/theme-data.css', base).toString(); + } else if (base.indexOf('file://') === 0) { + themeDataUrl = base.endsWith('/') + ? base.slice(0, -1) + '/theme-data.css' + : base.replace(/\/[^\/]+$/, '/theme-data.css'); } else { - // For HTTP/HTTPS URLs - same logic - if (base.endsWith('/')) { - themeDataUrl = base.slice(0, -1) + '/theme-data.css'; - } else { - themeDataUrl = base.replace(/\/[^\/]+$/, '/theme-data.css'); - } + themeDataUrl = '/theme-data.css'; } // Check for existing link and update href if stale diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts index b480db692..189395f76 100644 --- a/packages/dashboard/src/__tests__/server.test.ts +++ b/packages/dashboard/src/__tests__/server.test.ts @@ -446,6 +446,45 @@ describe("API Error Handling Middleware", () => { expect(res.body).not.toContain(" { + const app = createServer(store); + const res = await GET(app, "/tasks/FN-9999"); + + expect(res.status).toBe(301); + expect(res.headers.location).toBe("/?task=FN-9999"); + }); + + it("preserves project query when redirecting /tasks/:id", async () => { + const app = createServer(store); + const res = await GET(app, "/tasks/FN-9999?project=demo"); + + expect(res.status).toBe(301); + expect(res.headers.location).toBe("/?task=FN-9999&project=demo"); + }); + + it("does not redirect invalid /tasks/:id", async () => { + const app = createServer(store, { headless: true }); + const res = await GET(app, "/tasks/not-a-task"); + + expect(res.status).toBe(404); + expect(res.headers.location).toBeUndefined(); + }); + + it("keeps canonical query deep-link behavior unchanged", async () => { + const previousClientDir = process.env.FUSION_CLIENT_DIR; + process.env.FUSION_CLIENT_DIR = join(__dirname, "..", "..", "app"); + try { + const app = createServer(store); + const res = await GET(app, "/?task=FN-9999"); + + expect(res.status).toBe(200); + expect(typeof res.body).toBe("string"); + expect(res.body).toContain("
"); + } finally { + process.env.FUSION_CLIENT_DIR = previousClientDir; + } + }); }); describe("planning API route content types", () => { diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 2be420ecf..3c9168a46 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -1183,6 +1183,23 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT }); if (!isHeadless) { + app.get("/tasks/:id", (req, res, next) => { + const taskId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + if (!taskId || !/^[A-Z]+-\d+$/.test(taskId)) { + next(); + return; + } + + const params = new URLSearchParams(); + params.set("task", taskId); + const project = typeof req.query.project === "string" ? req.query.project : undefined; + if (project) { + params.set("project", project); + } + + res.redirect(301, `/?${params.toString()}`); + }); + // SPA fallback. Only serve index.html for navigation requests — never for // hashed asset URLs (/assets/*, /icons/*, /fonts/*) or any path that looks // like a static file. Returning index.html for a missing JS chunk poisons