feat(FN-3398): update desktop README with multi-project setup and troublesh

The merge lands Step 7 of FN-3398, adding documentation and delivery artifacts across the mobile and desktop packages with updated READMEs, mobile-specific docs, and architecture references.

Fusion-Task-Id: FN-3398
This commit is contained in:
Fusion
2026-05-04 21:41:52 -07:00
committed by gsxdsm
parent 9699c3f2b6
commit 3feeb9026a
43 changed files with 2025 additions and 398 deletions

View File

@@ -2,12 +2,14 @@ interface BackendConnectionErrorPageProps {
errorMessage: string;
isRetrying: boolean;
onRetry: () => void;
onManageConnection?: () => void;
}
export function BackendConnectionErrorPage({
errorMessage,
isRetrying,
onRetry,
onManageConnection,
}: BackendConnectionErrorPageProps) {
return (
<div className="project-overview-empty" role="alert" aria-live="polite">
@@ -16,9 +18,16 @@ export function BackendConnectionErrorPage({
Fusion couldn&apos;t load your projects right now. Please make sure the backend is running and try again.
</p>
<p className="settings-muted">Error: {errorMessage}</p>
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? "Retrying…" : "Retry Connection"}
</button>
<div className="modal-actions">
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? "Retrying…" : "Retry Connection"}
</button>
{onManageConnection && (
<button type="button" className="btn" onClick={onManageConnection}>
Manage Connection
</button>
)}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
import "./Header.css";
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
@@ -228,6 +228,7 @@ export interface HeaderProps {
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
}
export function Header({
@@ -277,6 +278,7 @@ export function Header({
isRemote = false,
experimentalFeatures,
pluginDashboardViews = [],
shellConnectionControl,
}: HeaderProps) {
const mode: ViewportMode = useViewportMode();
const isMobile = mode === "mobile";
@@ -992,6 +994,7 @@ export function Header({
</div>
<div className="header-actions">
{shellConnectionControl}
{/* Mobile View Toggle - compact board/list switcher in header when mobile nav is active */}
{hideFullNav && onChangeView && (view === "board" || view === "list") && (
<div className="view-toggle" data-testid="mobile-view-toggle">

View File

@@ -0,0 +1,41 @@
.native-shell-connection-manager {
width: min(100%, 42rem);
}
.native-shell-connection-manager__mode-row {
display: flex;
gap: var(--space-sm);
padding: 0 var(--space-xl) var(--space-md);
}
.native-shell-connection-manager__profiles {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: 0 var(--space-xl);
max-height: 16rem;
overflow: auto;
}
.native-shell-connection-manager__profile {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.native-shell-connection-manager__profile-actions {
display: inline-flex;
gap: var(--space-sm);
}
.native-shell-connection-manager__editor {
padding-top: var(--space-lg);
}
@media (max-width: 768px) {
.native-shell-connection-manager__profile {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import type { FusionShellApi, ShellConnectionProfile, ShellConnectionState } from "../types/native-shell";
import "./NativeShellConnectionManager.css";
interface NativeShellConnectionManagerProps {
open: boolean;
shellApi: FusionShellApi;
shellState: ShellConnectionState;
onClose: () => void;
}
export function NativeShellConnectionManager({ open, shellApi, shellState, onClose }: NativeShellConnectionManagerProps) {
const activeProfile = useMemo(
() => shellState.profiles.find((profile) => profile.id === shellState.activeProfileId) ?? null,
[shellState.activeProfileId, shellState.profiles],
);
const [draft, setDraft] = useState<Partial<ShellConnectionProfile>>({});
const [error, setError] = useState<string | null>(null);
if (!open) return null;
const workingName = draft.name ?? activeProfile?.name ?? "";
const workingUrl = draft.serverUrl ?? activeProfile?.serverUrl ?? "";
const workingToken = draft.authToken ?? activeProfile?.authToken ?? "";
const saveCurrent = async () => {
setError(null);
try {
const saved = await shellApi.saveProfile({
id: activeProfile?.id,
name: workingName || "Remote Server",
serverUrl: workingUrl,
authToken: workingToken || null,
});
await shellApi.setActiveProfile(saved.id);
setDraft({});
} catch (nextError) {
setError((nextError as Error).message);
}
};
return (
<div className="modal-overlay open">
<div className="modal native-shell-connection-manager" role="dialog" aria-label="Connection Manager">
<div className="modal-header">
<h2>Connection Manager</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
×
</button>
</div>
{shellState.host === "desktop-shell" && (
<div className="native-shell-connection-manager__mode-row">
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>Local</button>
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>Remote</button>
</div>
)}
<div className="native-shell-connection-manager__profiles">
{shellState.profiles.map((profile) => (
<div className="card native-shell-connection-manager__profile" key={profile.id}>
<div>
<strong>{profile.name}</strong>
<div className="settings-muted">{profile.serverUrl}</div>
</div>
<div className="native-shell-connection-manager__profile-actions">
<button type="button" className="btn btn-sm" onClick={() => setDraft(profile)}>Edit</button>
<button type="button" className="btn btn-sm" onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button>
<button type="button" className="btn btn-sm btn-danger" onClick={() => void shellApi.deleteProfile(profile.id)}>Delete</button>
</div>
</div>
))}
</div>
<div className="form-group native-shell-connection-manager__editor">
<label htmlFor="native-shell-connection-manager-name">Name</label>
<input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} />
<label htmlFor="native-shell-connection-manager-url">Server URL</label>
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
<label htmlFor="native-shell-connection-manager-token">Auth token (optional)</label>
<input id="native-shell-connection-manager-token" className="input" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
{error && <p className="form-error">{error}</p>}
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>Close</button>
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>Save</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
.native-shell-status {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.native-shell-status__label {
max-width: 18ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.native-shell-status__label {
max-width: 12ch;
}
}

View File

@@ -0,0 +1,22 @@
import type { ShellConnectionState } from "../types/native-shell";
import "./NativeShellConnectionStatus.css";
interface NativeShellConnectionStatusProps {
state: ShellConnectionState;
onManage: () => void;
}
export function NativeShellConnectionStatus({ state, onManage }: NativeShellConnectionStatusProps) {
const activeProfile = state.profiles.find((profile) => profile.id === state.activeProfileId) ?? null;
const label =
state.host === "desktop-shell" && state.desktopMode === "local"
? "Local Fusion"
: activeProfile?.name ?? "Disconnected";
return (
<button type="button" className="btn native-shell-status" onClick={onManage} data-testid="native-shell-status-btn">
<span className={`status-dot ${activeProfile || state.desktopMode === "local" ? "status-dot--online" : "status-dot--error"}`} aria-hidden="true" />
<span className="native-shell-status__label">{label}</span>
</button>
);
}

View File

@@ -0,0 +1,32 @@
.native-shell-onboarding-modal {
width: min(100%, calc(var(--space-2xl) * 20));
}
.native-shell-onboarding-body {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: 0 var(--space-xl) var(--space-xl);
}
.native-shell-onboarding-label {
color: var(--text-muted);
font-size: 0.75rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.native-shell-onboarding-mode-row {
display: flex;
gap: var(--space-sm);
}
@media (max-width: 768px) {
.native-shell-onboarding-modal {
width: calc(100% - var(--space-lg));
}
.native-shell-onboarding-mode-row {
flex-direction: column;
}
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useMemo, useState } from "react";
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
import "./NativeShellOnboardingModal.css";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
if (authToken) {
url.searchParams.set("token", authToken);
}
return url.toString();
}
interface NativeShellOnboardingModalProps {
open: boolean;
shellApi: FusionShellApi;
shellState: ShellConnectionState;
onComplete: () => void;
}
export function NativeShellOnboardingModal({ open, shellApi, shellState, onComplete }: NativeShellOnboardingModalProps) {
const [mode, setMode] = useState<"local" | "remote">(shellState.desktopMode ?? "remote");
const [name, setName] = useState("Remote Server");
const [serverUrl, setServerUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [error, setError] = useState<string | null>(null);
const isDesktop = shellState.host === "desktop-shell";
useEffect(() => {
if (isDesktop) {
setMode(shellState.desktopMode ?? "remote");
}
}, [isDesktop, shellState.desktopMode]);
const canSubmit = useMemo(() => {
if (isDesktop && mode === "local") return true;
return serverUrl.trim().length > 0;
}, [isDesktop, mode, serverUrl]);
if (!open) {
return null;
}
return (
<div className="modal-overlay open">
<div className="modal native-shell-onboarding-modal">
<div className="modal-header">
<h2>Welcome to Fusion</h2>
</div>
<div className="native-shell-onboarding-body">
<p>Fusion helps you plan, run, and review AI-assisted engineering work.</p>
{isDesktop && (
<div className="native-shell-onboarding-mode-row">
<button type="button" className={`btn ${mode === "local" ? "btn-primary" : ""}`} onClick={() => setMode("local")}>Local Fusion</button>
<button type="button" className={`btn ${mode === "remote" ? "btn-primary" : ""}`} onClick={() => setMode("remote")}>Remote Server</button>
</div>
)}
{(!isDesktop || mode === "remote") && (
<>
<button
type="button"
className="btn"
onClick={async () => {
setError(null);
try {
const result = await shellApi.startQrScan();
setServerUrl(result.serverUrl);
setAuthToken(result.authToken ?? "");
} catch (scanError) {
setError((scanError as Error).message);
}
}}
>
Scan QR
</button>
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-profile-name">Profile name</label>
<input id="native-shell-onboarding-profile-name" className="input" value={name} onChange={(event) => setName(event.target.value)} />
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-server-url">Server URL</label>
<input id="native-shell-onboarding-server-url" className="input" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder="https://your-fusion-host" />
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-auth-token">Auth token (optional)</label>
<input id="native-shell-onboarding-auth-token" className="input" value={authToken} onChange={(event) => setAuthToken(event.target.value)} />
</>
)}
{error && <p className="form-error">{error}</p>}
</div>
<div className="modal-actions">
<button
type="button"
className="btn btn-primary"
disabled={!canSubmit}
onClick={async () => {
setError(null);
try {
if (isDesktop && mode === "local") {
await shellApi.setDesktopMode("local");
onComplete();
return;
}
const saved = await shellApi.saveProfile({
name: name.trim() || "Remote Server",
serverUrl,
authToken: authToken || null,
});
if (isDesktop) {
await shellApi.setDesktopMode("remote");
}
await shellApi.setActiveProfile(saved.id);
if (typeof window !== "undefined" && shellState.host !== "web") {
window.location.href = buildRemoteDashboardUrl(saved.serverUrl, saved.authToken ?? null);
return;
}
onComplete();
} catch (submitError) {
setError((submitError as Error).message);
}
}}
>
Continue
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { NativeShellConnectionManager } from "../NativeShellConnectionManager";
function createShellApi() {
return {
getState: vi.fn(),
listProfiles: vi.fn(),
saveProfile: vi.fn(async () => ({ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" })),
deleteProfile: vi.fn(async () => undefined),
setActiveProfile: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] })),
setDesktopMode: vi.fn(async () => ({ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] })),
startQrScan: vi.fn(),
openConnectionManager: vi.fn(),
subscribe: vi.fn(() => () => undefined),
};
}
describe("NativeShellConnectionManager", () => {
it("switches desktop mode", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] }}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Local"));
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
});
it("edits and saves active profile", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }}
onClose={vi.fn()}
/>,
);
fireEvent.change(screen.getByDisplayValue("https://fusion.example.com"), { target: { value: "https://next.example.com" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(shellApi.saveProfile).toHaveBeenCalled();
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
});
});
});

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { NativeShellConnectionStatus } from "../NativeShellConnectionStatus";
describe("NativeShellConnectionStatus", () => {
it("shows local label for desktop local mode", () => {
render(
<NativeShellConnectionStatus
state={{ host: "desktop-shell", desktopMode: "local", activeProfileId: null, profiles: [] }}
onManage={vi.fn()}
/>,
);
expect(screen.getByText("Local Fusion")).toBeInTheDocument();
});
it("opens manager when clicked", () => {
const onManage = vi.fn();
render(
<NativeShellConnectionStatus
state={{ host: "mobile-shell", activeProfileId: "p1", profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", createdAt: "", updatedAt: "" }] }}
onManage={onManage}
/>,
);
fireEvent.click(screen.getByTestId("native-shell-status-btn"));
expect(onManage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,71 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { NativeShellOnboardingModal } from "../NativeShellOnboardingModal";
describe("NativeShellOnboardingModal", () => {
it("shows desktop mode options", () => {
render(
<NativeShellOnboardingModal
open={true}
shellApi={{
getState: vi.fn(),
listProfiles: vi.fn(),
saveProfile: vi.fn(),
deleteProfile: vi.fn(),
setActiveProfile: vi.fn(),
setDesktopMode: vi.fn(),
startQrScan: vi.fn(),
openConnectionManager: vi.fn(),
subscribe: vi.fn(() => () => undefined),
}}
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] }}
onComplete={vi.fn()}
/>,
);
expect(screen.getByText("Local Fusion")).toBeInTheDocument();
expect(screen.getByText("Remote Server")).toBeInTheDocument();
});
it("saves remote profile and redirects to remote dashboard", async () => {
const saveProfile = vi.fn(async () => ({ id: "p1", serverUrl: "https://fusion.example.com", authToken: null }));
const setActiveProfile = vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] }));
const onComplete = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: { ...originalLocation, href: "http://localhost" },
});
render(
<NativeShellOnboardingModal
open={true}
shellApi={{
getState: vi.fn(),
listProfiles: vi.fn(),
saveProfile,
deleteProfile: vi.fn(),
setActiveProfile,
setDesktopMode: vi.fn(),
startQrScan: vi.fn(),
openConnectionManager: vi.fn(),
subscribe: vi.fn(() => () => undefined),
}}
shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [] }}
onComplete={onComplete}
/>,
);
fireEvent.change(screen.getByPlaceholderText("https://your-fusion-host"), { target: { value: "https://fusion.example.com" } });
fireEvent.click(screen.getByText("Continue"));
await waitFor(() => {
expect(saveProfile).toHaveBeenCalled();
expect(setActiveProfile).toHaveBeenCalledWith("p1");
expect(window.location.href).toContain("https://fusion.example.com");
});
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
expect(onComplete).not.toHaveBeenCalled();
});
});