From ea834b955051874792637eb2bb09f8be0a9416b4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Apr 2026 22:00:56 -0700 Subject: [PATCH] feat(FN-1705): merge fusion/fn-1705 --- README.md | 2 +- docs/getting-started.md | 4 +- packages/dashboard/app/App.tsx | 4 + .../dashboard/app/components/AppModals.tsx | 4 + .../app/components/ModelOnboardingModal.tsx | 18 +- .../app/components/SettingsModal.tsx | 17 ++ .../app/components/__tests__/App.test.tsx | 188 +++++++++++++++++- .../__tests__/ModelOnboardingModal.test.tsx | 56 ++++++ .../__tests__/SettingsModal.test.tsx | 43 ++++ 9 files changed, 331 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 94a81cfd8..1f069f64c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Like Trello, but your tasks get specified, executed, and delivered by AI — pow 2. **GitHub (Optional)** — Connect GitHub for issue import and PR management 3. **First Task** — Create your first task or import from GitHub -The wizard is **dismissible and non-blocking** — click **Skip for now** to dismiss it and use the dashboard immediately. After dismissing, a **Continue Setup** banner appears at the top of the dashboard, letting you resume from where you left off. You can also re-trigger onboarding later from Settings → Authentication, or by clearing the `modelOnboardingComplete` flag in global settings. +The wizard is **dismissible and non-blocking** — click **Skip for now** to dismiss it and use the dashboard immediately. After dismissing, a **Continue Setup** banner appears at the top of the dashboard, letting you resume from where you left off. You can also re-trigger onboarding later from **Settings → Authentication → Reopen onboarding guide**. When reopening, the wizard pre-populates your saved AI provider and default model for quick review. ### Prerequisites diff --git a/docs/getting-started.md b/docs/getting-started.md index 42940eaac..44e34b2fc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,9 @@ On first launch, Fusion automatically opens the **onboarding wizard**. It guides **The onboarding wizard is dismissible and non-blocking.** If you skip setup, you can complete it later: - Click **Skip for now** to dismiss the wizard — the dashboard remains fully usable - After dismissing, a **Continue Setup** banner appears at the top of the dashboard, letting you resume from where you left off -- Re-open onboarding anytime from **Settings → Authentication**, or by clearing the `modelOnboardingComplete` flag in global settings +- Re-open onboarding anytime from **Settings → Authentication → Reopen onboarding guide** + +When reopening onboarding, the wizard pre-populates your previously saved AI provider and default model, so you can quickly review or update your setup. Onboarding completion is tracked by `modelOnboardingComplete` in global settings. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 8193b8f26..11890a6c2 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -565,6 +565,10 @@ function AppInner() { modalManager.closeSettings(); void refreshAppSettings(); }} + onReopenOnboarding={() => { + modalManager.closeSettings(); + modalManager.openModelOnboarding(); + }} /> ); diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 5331d1818..c503f9eb2 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -55,6 +55,8 @@ interface AppModalsProps { }; /** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */ onSettingsClose?: () => void; + /** Optional callback to reopen the onboarding guide from Settings. Closes Settings and opens ModelOnboardingModal. */ + onReopenOnboarding?: () => void; } export function AppModals({ @@ -72,6 +74,7 @@ export function AppModals({ deepLink, settings, onSettingsClose, + onReopenOnboarding, }: AppModalsProps) { // Use the override handler if provided, otherwise fall back to modalManager.closeSettings const handleSettingsClose = onSettingsClose ?? modalManager.closeSettings; @@ -110,6 +113,7 @@ export function AppModals({ colorTheme={settings.colorTheme} onThemeModeChange={settings.setThemeMode} onColorThemeChange={settings.setColorTheme} + onReopenOnboarding={onReopenOnboarding} /> )} diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 438a9b7c5..f7a02a8ad 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -103,12 +103,26 @@ export function ModelOnboardingModal({ } }, []); + // Load global settings to hydrate saved default model (for reopen flow) + const loadGlobalSettings = useCallback(async () => { + try { + const globalSettings = await fetchGlobalSettings(); + // If a default model is configured, pre-select it + if (globalSettings.defaultProvider && globalSettings.defaultModelId) { + const defaultModelValue = `${globalSettings.defaultProvider}/${globalSettings.defaultModelId}`; + setSelectedModel(defaultModelValue); + } + } catch { + // Silently fail - onboarding still works without hydration + } + }, []); + // Initial data load useEffect(() => { - Promise.all([loadAuthStatus(), loadModels()]).finally(() => + Promise.all([loadAuthStatus(), loadModels(), loadGlobalSettings()]).finally(() => setAuthLoading(false), ); - }, [loadAuthStatus, loadModels]); + }, [loadAuthStatus, loadModels, loadGlobalSettings]); // Check if we have GitHub provider const githubProvider = authProviders.find((p) => p.id === "github"); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 36deb4d0a..22956b739 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -92,6 +92,8 @@ interface SettingsModalProps { onThemeModeChange?: (mode: ThemeMode) => void; /** Called when color theme changes */ onColorThemeChange?: (theme: ColorTheme) => void; + /** Optional callback when user wants to reopen the onboarding guide */ + onReopenOnboarding?: () => void; } export function SettingsModal({ @@ -103,6 +105,7 @@ export function SettingsModal({ colorTheme = "default", onThemeModeChange, onColorThemeChange, + onReopenOnboarding, }: SettingsModalProps) { const [form, setForm] = useState({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: true, mergeStrategy: "direct", recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "", ntfyEnabled: false, ntfyTopic: undefined }); const [loading, setLoading] = useState(true); @@ -2400,6 +2403,20 @@ export function SettingsModal({ Authentication changes take effect immediately — no need to save. + {onReopenOnboarding && ( +
+ + + Re-run the setup wizard to review or update your AI provider and model configuration. + +
+ )} ); } diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index fde6617f9..80252001a 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -114,6 +114,21 @@ vi.mock("../../components/model-onboarding-state", () => ({ clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args), })); +// Mock CustomModelDropdown for onboarding modal tests +vi.mock("../../components/CustomModelDropdown", () => ({ + CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => ( + + ), +})); + // Mock state holders for dynamic mocking const mockProjectsState = { projects: [] as any[], @@ -177,7 +192,7 @@ vi.mock("../../hooks/useNodes", () => ({ })); import { App } from "../../App"; -import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api"; +import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts, fetchModels } from "../../api"; import * as apiNodeModule from "../../hooks/useRemoteNodeData"; beforeEach(() => { @@ -1983,3 +1998,174 @@ describe("App search query propagation to remote mode", () => { }); }); }); + +describe("App onboarding reopen", () => { + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + }); + + it("does not auto-open onboarding when modelOnboardingComplete is true and setup is complete", async () => { + // Mock fetchGlobalSettings to return complete onboarding with default model + (fetchGlobalSettings as ReturnType).mockResolvedValue({ + modelOnboardingComplete: true, + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }); + + render(); + + await waitFor(() => { + expect(fetchSettings).toHaveBeenCalled(); + }); + + // Wait for initial load + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + + // Onboarding modal should NOT be open + expect(screen.queryByText("Set Up AI")).toBeNull(); + }); + + it("opens Settings → Authentication → Reopen onboarding guide opens onboarding modal", async () => { + // Mock fetchGlobalSettings to return complete onboarding (to avoid auto-open on first call) + // and hydrated settings on subsequent calls + (fetchGlobalSettings as ReturnType) + .mockResolvedValueOnce({ + modelOnboardingComplete: true, + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }) + .mockResolvedValue({ + modelOnboardingComplete: true, + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }); + + // Mock Settings and auth + (fetchSettings as ReturnType).mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + }); + (fetchAuthStatus as ReturnType).mockResolvedValue({ + providers: [ + { id: "anthropic", name: "Anthropic", authenticated: true }, + ], + }); + (fetchModels as ReturnType).mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 }, + ], + favoriteProviders: [], + favoriteModels: [], + }); + + render(); + + await waitFor(() => { + expect(fetchSettings).toHaveBeenCalled(); + }); + + // Wait for initial load + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + + // Onboarding should NOT be open initially + expect(screen.queryByText("Set Up AI")).toBeNull(); + + // Open Settings via header + const settingsBtn = screen.getByRole("button", { name: /settings/i }); + fireEvent.click(settingsBtn); + + await waitFor(() => { + expect(screen.getByText("Settings")).toBeTruthy(); + }); + + // Navigate to Authentication section (it should be default or click to ensure) + const authSection = screen.getAllByText("Authentication")[0]; + fireEvent.click(authSection); + + await waitFor(() => { + expect(fetchAuthStatus).toHaveBeenCalled(); + }); + + // Click Reopen onboarding guide button + const reopenBtn = screen.getByText("Reopen onboarding guide"); + fireEvent.click(reopenBtn); + + // Onboarding modal should now be open + await waitFor(() => { + expect(screen.getByText("Set Up AI")).toBeTruthy(); + }); + }); + + it("reopened modal shows hydrated model state from global settings", async () => { + // Mock fetchGlobalSettings to return hydrated settings + (fetchGlobalSettings as ReturnType).mockResolvedValue({ + modelOnboardingComplete: true, + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }); + + // Mock Settings and auth + (fetchSettings as ReturnType).mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + }); + (fetchAuthStatus as ReturnType).mockResolvedValue({ + providers: [ + { id: "anthropic", name: "Anthropic", authenticated: true }, + ], + }); + (fetchModels as ReturnType).mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 }, + ], + favoriteProviders: [], + favoriteModels: [], + }); + + render(); + + await waitFor(() => { + expect(fetchSettings).toHaveBeenCalled(); + }); + + // Wait for initial load + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + + // Open Settings via header + const settingsBtn = screen.getByRole("button", { name: /settings/i }); + fireEvent.click(settingsBtn); + + await waitFor(() => { + expect(screen.getByText("Settings")).toBeTruthy(); + }); + + // Navigate to Authentication section + const authSection = screen.getAllByText("Authentication")[0]; + fireEvent.click(authSection); + + await waitFor(() => { + expect(fetchAuthStatus).toHaveBeenCalled(); + }); + + // Click Reopen onboarding guide button + const reopenBtn = screen.getByText("Reopen onboarding guide"); + fireEvent.click(reopenBtn); + + // Wait for onboarding modal to open + await waitFor(() => { + expect(screen.getByText("Set Up AI")).toBeTruthy(); + }); + + // The model dropdown should be pre-populated with the saved default + // Check that the dropdown shows the saved model is selected + const dropdown = await screen.findByTestId("mock-model-dropdown"); + expect((dropdown as HTMLSelectElement).value).toBe("anthropic/claude-sonnet-4-5"); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index e31a883c3..6c2d5a08f 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -10,6 +10,7 @@ const mockLogoutProvider = vi.fn(); const mockSaveApiKey = vi.fn(); const mockClearApiKey = vi.fn(); const mockFetchModels = vi.fn(); +const mockFetchGlobalSettings = vi.fn(); const mockUpdateGlobalSettings = vi.fn(); vi.mock("../../api", () => ({ @@ -19,6 +20,7 @@ vi.mock("../../api", () => ({ saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args), clearApiKey: (...args: unknown[]) => mockClearApiKey(...args), fetchModels: (...args: unknown[]) => mockFetchModels(...args), + fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args), updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args), })); @@ -81,6 +83,7 @@ beforeEach(() => { vi.clearAllMocks(); mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders }); mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] }); + mockFetchGlobalSettings.mockResolvedValue({}); mockUpdateGlobalSettings.mockResolvedValue({}); mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" }); mockLogoutProvider.mockResolvedValue({ success: true }); @@ -654,4 +657,57 @@ describe("ModelOnboardingModal", () => { }); }); }); + + describe("global settings hydration", () => { + it("pre-populates selectedModel from global settings defaultProvider/defaultModelId", async () => { + // Mock global settings with a saved default model + mockFetchGlobalSettings.mockResolvedValueOnce({ + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + modelOnboardingComplete: true, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Set Up AI")).toBeTruthy(); + }); + + // The model dropdown should be pre-populated with the saved default + const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement; + expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5"); + }); + + it("leaves selectedModel empty when no default is configured in global settings", async () => { + // Mock global settings with no default model + mockFetchGlobalSettings.mockResolvedValueOnce({ + modelOnboardingComplete: true, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Set Up AI")).toBeTruthy(); + }); + + // The model dropdown should be empty + const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement; + expect(dropdown.value).toBe(""); + }); + + it("handles fetchGlobalSettings failure gracefully", async () => { + // Mock global settings fetch to fail + mockFetchGlobalSettings.mockRejectedValueOnce(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Set Up AI")).toBeTruthy(); + }); + + // The modal should still render with empty dropdown + const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement; + expect(dropdown.value).toBe(""); + }); + }); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 262e619ec..f99e44075 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -3127,4 +3127,47 @@ describe("Prompts section", () => { expect(payload.agentPrompts.templates.length).toBe(1); expect(payload.agentPrompts.templates[0].name).toBe("My Custom Template"); }); + + describe("Reopen onboarding guide", () => { + it("renders Reopen onboarding guide button in Authentication section when onReopenOnboarding is provided", async () => { + const onReopenOnboarding = vi.fn(); + render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + // Navigate to Authentication section + fireEvent.click(screen.getAllByText("Authentication")[0]); + await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); + + // Check that the reopen button is rendered + expect(screen.getByText("Reopen onboarding guide")).toBeTruthy(); + }); + + it("does not render Reopen onboarding guide button when onReopenOnboarding is not provided", async () => { + render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + // Navigate to Authentication section + fireEvent.click(screen.getAllByText("Authentication")[0]); + await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); + + // Check that the reopen button is NOT rendered + expect(screen.queryByText("Reopen onboarding guide")).toBeNull(); + }); + + it("calls onReopenOnboarding when Reopen button is clicked", async () => { + const onReopenOnboarding = vi.fn(); + render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + // Navigate to Authentication section + fireEvent.click(screen.getAllByText("Authentication")[0]); + await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); + + // Click the reopen button + fireEvent.click(screen.getByText("Reopen onboarding guide")); + + // Verify callback was called + expect(onReopenOnboarding).toHaveBeenCalledTimes(1); + }); + }); });