feat(FN-3407): normalize shell host bootstrap

Introduces a canonical shell host context and normalized bootstrap flow for the dashboard, extracting the host initialization logic into a dedicated `shell-host.ts` module with a `ShellHostContext` provider; updates `App.tsx` and `Header` to use the new context, stabilizes related tests, and documen

Fusion-Task-Id: FN-3407
This commit is contained in:
Fusion
2026-05-07 22:56:16 -07:00
committed by gsxdsm
parent b00906ff96
commit 9e5bc7ca72
11 changed files with 432 additions and 23 deletions

View File

@@ -15,6 +15,23 @@ When running inside Fusion mobile or desktop shells, the dashboard uses a host-n
The shared dashboard must use `window.fusionShell` for shell connectivity concerns (not direct Electron or Capacitor globals).
## Canonical dashboard host-context contract
Dashboard host detection is centralized in `app/shell-host.ts` and exposed to React via `ShellHostProvider` (`app/context/ShellHostContext.tsx`).
Canonical contract:
- `{ kind: "browser" }`
- `{ kind: "desktop-shell", mode?, connectionId?, serverUrl?, canOpenConnectionManager? }`
- `{ kind: "mobile-shell", mode?, connectionId?, serverUrl?, canOpenConnectionManager? }`
Bootstrap priority is fixed:
1. explicit bootstrapped global handoff (`__FUSION_SHELL_HOST_CONTEXT__` / compatibility aliases)
2. shell handoff query params
3. desktop fallback via `window.fusionAPI` presence
4. browser fallback
Shell launch query params are removed after bootstrap. UI components should consume `useShellHostContext()` instead of reading `window` globals directly.
## Features
### Planning Mode

View File

@@ -56,8 +56,8 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import { ShellProvider } from "./context/ShellContext";
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
import { useShellConnection } from "./hooks/useShellConnection";
import { useShellContext as useLaunchShellContext } from "./hooks/useShellContext";
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
import { NativeShellConnectionStatus } from "./components/NativeShellConnectionStatus";
@@ -154,8 +154,7 @@ export function requiresNativeShellOnboarding(
function AppInner() {
const { toasts, addToast, removeToast } = useToast();
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
const { shellContext } = useLaunchShellContext();
const isElectron = shellContext?.shellKind === "desktop";
const shellHost = useShellHostContext();
// Warm lazy view chunks during browser idle so first navigation is instant.
useEffect(() => {
@@ -1316,8 +1315,7 @@ function AppInner() {
return (
<>
<Header
isElectron={isElectron}
shellContext={shellContext}
shellHost={shellHost.host}
onOpenSettings={openSettingsWithNav}
onOpenGitHubImport={openGitHubImportWithNav}
onOpenPlanning={openPlanningWithNav}
@@ -1547,13 +1545,15 @@ function AppInner() {
export function App() {
return (
<ToastProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
<ShellHostProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
</ShellHostProvider>
</ToastProvider>
);
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
__resetShellHostContextForTests,
bootstrapShellHostContext,
detectShellHostContext,
} from "../shell-host";
describe("shell-host", () => {
beforeEach(() => {
__resetShellHostContextForTests();
window.history.replaceState({}, "", "/");
delete (window as Window & { fusionAPI?: unknown }).fusionAPI;
delete (window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__;
});
it("falls back to browser when no shell signals are present", () => {
expect(detectShellHostContext()).toEqual({ kind: "browser" });
});
it("detects desktop shell via fusionAPI fallback", () => {
(window as Window & { fusionAPI?: unknown }).fusionAPI = {};
expect(detectShellHostContext()).toEqual({ kind: "desktop-shell" });
});
it("normalizes explicit global handoff", () => {
(window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__ = {
kind: "mobile-shell",
mode: "remote",
connectionId: "conn-1",
serverUrl: "https://fusion.example.com/",
canOpenConnectionManager: true,
};
expect(detectShellHostContext()).toEqual({
kind: "mobile-shell",
mode: "remote",
connectionId: "conn-1",
serverUrl: "https://fusion.example.com",
canOpenConnectionManager: true,
});
});
it("maps FN-3406 legacy query params into canonical contract", () => {
window.history.replaceState({}, "", "/?shellKind=desktop&shellMode=remote&profileId=p1&serverBaseUrl=https%3A%2F%2Fremote.example.com%2F&shellCanOpenConnectionManager=1");
expect(detectShellHostContext()).toEqual({
kind: "desktop-shell",
mode: "remote",
connectionId: "p1",
serverUrl: "https://remote.example.com",
canOpenConnectionManager: true,
});
});
it("handles malformed handoff values without crashing", () => {
window.history.replaceState({}, "", "/?shellKind=mobile&shellMode=invalid&serverBaseUrl=notaurl");
expect(detectShellHostContext()).toEqual({ kind: "mobile-shell" });
});
it("strips shell launch params from URL at bootstrap", () => {
window.history.replaceState({}, "", "/dashboard?view=board&shellKind=desktop&shellMode=remote&profileId=p1#section");
bootstrapShellHostContext();
expect(window.location.pathname + window.location.search + window.location.hash).toBe("/dashboard?view=board#section");
});
});

View File

@@ -15,7 +15,7 @@ import type { TaskView } from "../hooks/useViewState";
import type { PluginDashboardViewEntry } from "../api";
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
import type { ShellContext as LaunchShellContext } from "../shell-context";
import type { ShellHostContext } from "../shell-host";
export { useViewportMode };
@@ -225,7 +225,7 @@ export interface HeaderProps {
onSelectProject?: (project: ProjectInfo) => void;
onViewAllProjects?: () => void;
projectId?: string;
isElectron?: boolean;
shellHost?: ShellHostContext;
/** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */
mobileNavEnabled?: boolean;
/** Available nodes for the node selector */
@@ -240,7 +240,6 @@ export interface HeaderProps {
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
shellContext?: LaunchShellContext | null;
}
export function Header({
@@ -289,7 +288,7 @@ export function Header({
onSelectProject,
onViewAllProjects,
projectId,
isElectron = false,
shellHost = { kind: "browser" },
mobileNavEnabled,
availableNodes = [],
currentNode,
@@ -298,7 +297,6 @@ export function Header({
experimentalFeatures,
pluginDashboardViews = [],
shellConnectionControl,
shellContext,
}: HeaderProps) {
const mode: ViewportMode = useViewportMode();
const isMobile = mode === "mobile";
@@ -847,9 +845,11 @@ export function Header({
if (onSearchChange) onSearchChange("");
}, [onSearchChange]);
const isDesktopShell = shellHost.kind === "desktop-shell";
return (
<div className="header-wrapper">
<header className="header" data-shell-kind={shellContext?.shellKind ?? "web"}>
<header className="header" data-shell-kind={shellHost.kind}>
<div className="header-left">
<div className="header-brand">
<svg
@@ -1361,7 +1361,7 @@ export function Header({
)}
{/* Desktop actions */}
{!isCompact && !isElectron && (
{!isCompact && !isDesktopShell && (
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<GitHubLogo size={16} />
</button>
@@ -1726,7 +1726,7 @@ export function Header({
<span>Nodes</span>
</button>
)}
{!isElectron && (
{!isDesktopShell && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenGitHubImport)}

View File

@@ -155,6 +155,34 @@ vi.mock("../../context/NodeContext", () => ({
useNodeContext: vi.fn(() => mockNodeContextValue),
}));
const mockShellHostContextValue = {
host: { kind: "browser" as const },
isNativeShell: false,
kind: "browser" as const,
};
vi.mock("../../context/ShellHostContext", () => ({
ShellHostProvider: ({ children }: { children: React.ReactNode }) => children,
useShellHostContext: vi.fn(() => mockShellHostContextValue),
}));
const mockShellConnectionState = {
host: "web" as const,
desktopMode: "local" as const,
profiles: [],
activeProfileId: null,
localServer: null,
};
vi.mock("../../hooks/useShellConnection", () => ({
useShellConnection: vi.fn(() => ({
shellApi: null,
state: mockShellConnectionState,
ready: true,
openConnectionManagerSignal: 0,
})),
}));
// Mock model-onboarding-state
const mockIsOnboardingResumable = vi.fn();
const mockGetOnboardingResumeStep = vi.fn();
@@ -502,6 +530,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
import { App } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
import { __resetShellHostContextForTests } from "../../shell-host";
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
async function waitForAppShell(): Promise<void> {
@@ -513,6 +542,7 @@ async function waitForAppShell(): Promise<void> {
beforeEach(() => {
vi.clearAllMocks();
__resetShellHostContextForTests();
localStorage.clear();
mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn());

View File

@@ -63,9 +63,9 @@ describe("Header", () => {
expect(screen.getByText("Fusion")).toBeDefined();
});
it("applies shell context metadata on the header root", () => {
const { container } = renderHeader({ shellContext: { shellKind: "desktop", shellMode: "remote", capabilities: { canOpenConnectionManager: true } } });
expect(container.querySelector("header.header")?.getAttribute("data-shell-kind")).toBe("desktop");
it("applies shell host metadata on the header root", () => {
const { container } = renderHeader({ shellHost: { kind: "desktop-shell", mode: "remote", canOpenConnectionManager: true } });
expect(container.querySelector("header.header")?.getAttribute("data-shell-kind")).toBe("desktop-shell");
});
it("renders action buttons", () => {
@@ -74,6 +74,16 @@ describe("Header", () => {
expect(screen.getByTitle("Settings")).toBeDefined();
});
it("hides GitHub import for desktop shell host", () => {
renderHeader({ shellHost: { kind: "desktop-shell" } });
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
});
it("keeps GitHub import for mobile shell host", () => {
renderHeader({ shellHost: { kind: "mobile-shell" } });
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
});
it("renders system stats button on desktop when handler is provided", () => {
renderHeader({ onOpenSystemStats: vi.fn() }, "desktop");
expect(screen.getByTitle("System Stats")).toBeDefined();

View File

@@ -0,0 +1,44 @@
import { createContext, useContext, useMemo, type PropsWithChildren } from "react";
import { getShellHostContext, type ShellHostContext } from "../shell-host";
export interface ShellHostContextValue {
host: ShellHostContext;
isNativeShell: boolean;
kind: ShellHostContext["kind"];
mode?: "local" | "remote";
connectionId?: string;
serverUrl?: string;
canOpenConnectionManager?: boolean;
}
const DEFAULT_HOST: ShellHostContext = { kind: "browser" };
const ShellHostContextReact = createContext<ShellHostContextValue>({
host: DEFAULT_HOST,
isNativeShell: false,
kind: "browser",
});
function buildValue(host: ShellHostContext): ShellHostContextValue {
const isNativeShell = host.kind !== "browser";
return {
host,
isNativeShell,
kind: host.kind,
...(isNativeShell ? {
mode: host.mode,
connectionId: host.connectionId,
serverUrl: host.serverUrl,
canOpenConnectionManager: host.canOpenConnectionManager,
} : {}),
};
}
export function ShellHostProvider({ children }: PropsWithChildren) {
const value = useMemo(() => buildValue(getShellHostContext()), []);
return <ShellHostContextReact.Provider value={value}>{children}</ShellHostContextReact.Provider>;
}
export function useShellHostContext(): ShellHostContextValue {
return useContext(ShellHostContextReact);
}

View File

@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { ShellHostProvider, useShellHostContext } from "../ShellHostContext";
import { __resetShellHostContextForTests } from "../../shell-host";
function Probe() {
const value = useShellHostContext();
return <pre data-testid="host">{JSON.stringify(value)}</pre>;
}
describe("ShellHostContext", () => {
beforeEach(() => {
__resetShellHostContextForTests();
window.history.replaceState({}, "", "/");
delete (window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__;
});
it("provides browser defaults", () => {
render(
<ShellHostProvider>
<Probe />
</ShellHostProvider>,
);
const value = JSON.parse(screen.getByTestId("host").textContent ?? "{}");
expect(value.kind).toBe("browser");
expect(value.isNativeShell).toBe(false);
});
it("provides normalized shell fields", () => {
(window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__ = {
kind: "desktop-shell",
mode: "remote",
connectionId: "conn-2",
serverUrl: "https://remote.example.com",
canOpenConnectionManager: true,
};
render(
<ShellHostProvider>
<Probe />
</ShellHostProvider>,
);
const value = JSON.parse(screen.getByTestId("host").textContent ?? "{}");
expect(value.isNativeShell).toBe(true);
expect(value.kind).toBe("desktop-shell");
expect(value.mode).toBe("remote");
expect(value.connectionId).toBe("conn-2");
expect(value.serverUrl).toBe("https://remote.example.com");
expect(value.canOpenConnectionManager).toBe(true);
});
});

View File

@@ -5,6 +5,7 @@ import { App } from "./App";
import { installAuthFetch } from "./auth";
import { installVersionCheck } from "./versionCheck";
import { installSwUpdate } from "./swUpdate";
import { bootstrapShellHostContext } from "./shell-host";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
@@ -13,6 +14,7 @@ import "./styles.css";
// stored from a previous session.
installAuthFetch();
installVersionCheck();
bootstrapShellHostContext();
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -0,0 +1,179 @@
export type ShellHostKind = "browser" | "desktop-shell" | "mobile-shell";
export type ShellHostMode = "local" | "remote";
export type ShellHostContext =
| { kind: "browser" }
| {
kind: "desktop-shell" | "mobile-shell";
mode?: ShellHostMode;
connectionId?: string;
serverUrl?: string;
canOpenConnectionManager?: boolean;
};
export const SHELL_HOST_QUERY_KEYS = [
"shellKind",
"shellMode",
"profileId",
"serverBaseUrl",
"serverLabel",
"shellCanOpenConnectionManager",
"hostKind",
"mode",
"connectionId",
"serverUrl",
"canOpenConnectionManager",
] as const;
const BOOTSTRAP_GLOBAL_KEYS = [
"__FUSION_SHELL_HOST_CONTEXT__",
"__fusionShellHostContext",
"__FUSION_SHELL_CONTEXT__",
] as const;
let cachedContext: ShellHostContext | null = null;
let bootstrapped = false;
function parseBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (typeof value !== "string") return undefined;
if (value === "1" || value === "true") return true;
if (value === "0" || value === "false") return false;
return undefined;
}
function normalizeServerUrl(value: unknown): string | undefined {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
return parsed.toString().replace(/\/$/, "");
} catch {
return undefined;
}
}
function normalizeKind(value: unknown): ShellHostKind | undefined {
if (value === "browser" || value === "desktop-shell" || value === "mobile-shell") return value;
if (value === "desktop") return "desktop-shell";
if (value === "mobile") return "mobile-shell";
return undefined;
}
function normalizeMode(value: unknown): ShellHostMode | undefined {
return value === "local" || value === "remote" ? value : undefined;
}
function normalizeNativeHost(input: {
kind: "desktop-shell" | "mobile-shell";
mode?: unknown;
connectionId?: unknown;
serverUrl?: unknown;
canOpenConnectionManager?: unknown;
}): ShellHostContext {
const mode = normalizeMode(input.mode);
const connectionId = typeof input.connectionId === "string" && input.connectionId.trim() ? input.connectionId : undefined;
const serverUrl = normalizeServerUrl(input.serverUrl);
const canOpenConnectionManager = parseBoolean(input.canOpenConnectionManager);
return {
kind: input.kind,
...(mode ? { mode } : {}),
...(connectionId ? { connectionId } : {}),
...(serverUrl ? { serverUrl } : {}),
...(canOpenConnectionManager !== undefined ? { canOpenConnectionManager } : {}),
};
}
function fromBootstrapGlobal(target: Window): ShellHostContext | null {
for (const key of BOOTSTRAP_GLOBAL_KEYS) {
const raw = (target as Window & Record<string, unknown>)[key];
if (!raw || typeof raw !== "object") continue;
const record = raw as Record<string, unknown>;
const kind = normalizeKind(record.kind ?? record.shellKind ?? record.hostKind);
if (!kind) continue;
if (kind === "browser") return { kind };
return normalizeNativeHost({
kind,
mode: record.mode ?? record.shellMode,
connectionId: record.connectionId ?? record.profileId,
serverUrl: record.serverUrl ?? record.serverBaseUrl,
canOpenConnectionManager: record.canOpenConnectionManager ?? record.shellCanOpenConnectionManager,
});
}
return null;
}
function fromQuery(target: Window): ShellHostContext | null {
let url: URL;
try {
url = new URL(target.location.href);
} catch {
return null;
}
const params = url.searchParams;
const kind = normalizeKind(params.get("hostKind") ?? params.get("kind") ?? params.get("shellKind"));
if (!kind) return null;
if (kind === "browser") return { kind };
return normalizeNativeHost({
kind,
mode: params.get("mode") ?? params.get("shellMode"),
connectionId: params.get("connectionId") ?? params.get("profileId"),
serverUrl: params.get("serverUrl") ?? params.get("serverBaseUrl"),
canOpenConnectionManager: params.get("canOpenConnectionManager") ?? params.get("shellCanOpenConnectionManager"),
});
}
function stripShellQueryParams(target: Window): void {
try {
const url = new URL(target.location.href);
let changed = false;
for (const key of SHELL_HOST_QUERY_KEYS) {
if (url.searchParams.has(key)) {
url.searchParams.delete(key);
changed = true;
}
}
if (!changed) return;
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
target.history.replaceState(target.history.state, "", cleaned);
} catch {
// no-op
}
}
export function detectShellHostContext(target: Window = window): ShellHostContext {
const globalContext = fromBootstrapGlobal(target);
if (globalContext) return globalContext;
const queryContext = fromQuery(target);
if (queryContext) return queryContext;
if (typeof (target as Window & { fusionAPI?: unknown }).fusionAPI !== "undefined") {
return { kind: "desktop-shell" };
}
return { kind: "browser" };
}
export function bootstrapShellHostContext(target: Window = window): ShellHostContext {
if (!bootstrapped && typeof window !== "undefined") {
cachedContext = detectShellHostContext(target);
stripShellQueryParams(target);
bootstrapped = true;
}
return cachedContext ?? { kind: "browser" };
}
export function getShellHostContext(): ShellHostContext {
if (cachedContext) return cachedContext;
if (typeof window === "undefined") return { kind: "browser" };
return bootstrapShellHostContext(window);
}
export function __resetShellHostContextForTests(): void {
cachedContext = null;
bootstrapped = false;
}