feat(FN-3792): add /tasks deep-link support with theme-data URL resolution

Adds a `/tasks` deep-link that rewrites legacy hash-style URLs on the client and redirects root-absolute paths on the server (FN-3792), along with a new `fusion-plugin-reports` scaffold including manifest, settings, and notification service improvements (FN-3778, FN-3790); also adds a session switch

Fusion-Task-Id: FN-3792
This commit is contained in:
Fusion
2026-05-08 23:24:04 -07:00
committed by gsxdsm
parent f47386bbb4
commit 18413a1c42
8 changed files with 230 additions and 53 deletions

View File

@@ -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 () => {

View File

@@ -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");
});

View File

@@ -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<string | null>(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");

View File

@@ -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');

View File

@@ -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

View File

@@ -446,6 +446,45 @@ describe("API Error Handling Middleware", () => {
expect(res.body).not.toContain("<html");
}
});
it("redirects /tasks/:id to canonical ?task query", async () => {
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("<div id=\"root\"></div>");
} finally {
process.env.FUSION_CLIENT_DIR = previousClientDir;
}
});
});
describe("planning API route content types", () => {

View File

@@ -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