feat(FN-1705): merge fusion/fn-1705

This commit is contained in:
gsxdsm
2026-04-14 22:00:56 -07:00
parent a3d100e7dc
commit 513e6e650e
9 changed files with 331 additions and 5 deletions

View File

@@ -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}
/>
</ModalErrorBoundary>
)}

View File

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

View File

@@ -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<Settings & { worktreeInitCommand?: string }>({ 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({
<small className="auth-hint">
Authentication changes take effect immediately no need to save.
</small>
{onReopenOnboarding && (
<div className="form-group" style={{ marginTop: "var(--space-md)" }}>
<button
type="button"
className="btn btn-sm"
onClick={onReopenOnboarding}
>
Reopen onboarding guide
</button>
<small className="settings-muted">
Re-run the setup wizard to review or update your AI provider and model configuration.
</small>
</div>
)}
</>
);
}

View File

@@ -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 }) => (
<select
data-testid="mock-model-dropdown"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder ?? "Select…"}</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
// 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<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
render(<App />);
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<typeof vi.fn>)
.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<typeof vi.fn>).mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
});
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
render(<App />);
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<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
// Mock Settings and auth
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
});
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
render(<App />);
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");
});
});

View File

@@ -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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
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("");
});
});
});

View File

@@ -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(<SettingsModal onClose={onClose} addToast={addToast} onReopenOnboarding={onReopenOnboarding} />);
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(<SettingsModal onClose={onClose} addToast={addToast} />);
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(<SettingsModal onClose={onClose} addToast={addToast} onReopenOnboarding={onReopenOnboarding} />);
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);
});
});
});