feat(FN-1705): merge fusion/fn-1705
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user