diff --git a/.changeset/fix-onboarding-default-model-prompt.md b/.changeset/fix-onboarding-default-model-prompt.md new file mode 100644 index 0000000000..f8e8613efc --- /dev/null +++ b/.changeset/fix-onboarding-default-model-prompt.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Onboarding now offers a default model as soon as a provider connects, instead of staying empty. +category: fix +dev: `availableModels` was fetched at mount and re-fetched only for custom providers, so on a fresh install the Default Model section stayed on "No models available yet. Connect a provider above to see model options." after an OAuth login or API-key save, and no default was ever offered. Both connect paths now refresh the catalogue; once a provider is connected with nothing chosen, the section retitles to "Choose your default model" and scrolls into view once (guarded — JSDOM and non-DOM hosts have no scrollIntoView). Completion is also marked in a `finally` so a failed settings write cannot strand onboarding as unfinished. diff --git a/Dockerfile b/Dockerfile index 234ad23fa3..f3d7a9f3da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -159,6 +159,22 @@ RUN chown node:node /app \ USER node +# FNXC:DockerRun 2026-08-18-06:55: A DEFAULT GIT IDENTITY, because a container has none and Fusion +# mostly commits with whatever git finds in ambient config. Only `workspace-fence-ref.ts` passes +# `-c user.name/-c user.email` explicitly; the merge commits, the `--amend` in merger-ai, and the +# experiment git-ops all rely on the environment. With no identity every one of them dies on +# "Author identity unknown ... Please tell me who you are", so an auto-merge reached `status:merging` +# and stopped there with nothing in the UI to explain why (operator report). +# +# The values match the identity Fusion already uses for its own fence commits, so authorship stays +# consistent; an operator who wants real authorship overrides it with `git config --global` in a +# mounted home or a derived image. This is a FALLBACK for the container, not a substitute for +# passing an explicit identity at the commit sites — those should still be fixed upstream so a bare +# machine with no git config behaves the same way. +RUN git config --global user.name "Fusion" \ + && git config --global user.email "fusion@localhost" \ + && git config --global init.defaultBranch main + WORKDIR /workspace EXPOSE 4040 diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 3da6a69e2a..f0b6333bc3 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -827,6 +827,18 @@ export function ModelOnboardingModal({ const [loginErrors, setLoginErrors] = useState>({}); const [availableModels, setAvailableModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); + /* + FNXC:Onboarding 2026-08-18-06:20: + Picking a default model is the step operators walked past: the section sits below every provider + card, reads "(Optional)", and on a fresh install renders as an empty-state until a provider + connects — so the moment it becomes actionable is exactly the moment it is off screen. Once a + provider is connected and no model is chosen yet, bring the section into view ONCE and mark it as + awaiting a choice. It stays skippable; `nudged` makes sure a later re-render never yanks the + operator's scroll position back again. + */ + const modelSectionRef = useRef(null); + const modelPromptNudgedRef = useRef(false); + const [modelChoicePending, setModelChoicePending] = useState(false); const [saving, setSaving] = useState(false); const [apiKeyInputs, setApiKeyInputs] = useState>({}); const [apiKeyErrors, setApiKeyErrors] = useState>({}); @@ -1378,6 +1390,28 @@ export function ModelOnboardingModal({ }, [agentDraft, handleNext, projectId, t]); // OAuth login handler + /* + FNXC:Onboarding 2026-08-18-06:20: + The nudge fires only when the choice is actually possible — a provider connected AND its models + loaded AND nothing selected — because before that the section is an empty state and scrolling to + it would just show the operator "No models available yet". + */ + useEffect(() => { + const connected = authProviders.some((provider) => provider.authenticated && provider.id !== "github"); + const canChoose = connected && availableModels.length > 0 && !selectedModel; + setModelChoicePending(canChoose); + if (!canChoose || modelPromptNudgedRef.current) { + return; + } + modelPromptNudgedRef.current = true; + // Guarded: scrollIntoView is absent in JSDOM and in any non-DOM host, and a nudge is never + // important enough to throw out of an effect and take the modal down with it. + const section = modelSectionRef.current; + if (typeof section?.scrollIntoView === "function") { + section.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [authProviders, availableModels, selectedModel]); + const handleLogin = useCallback( async (providerId: string) => { const provider = authProviders.find((entry) => entry.id === providerId); @@ -1536,6 +1570,16 @@ export function ModelOnboardingModal({ } setAuthActionInProgress(null); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" })); + /* + FNXC:Onboarding 2026-08-18-06:20: + A NEWLY CONNECTED PROVIDER MUST REFRESH THE MODEL CATALOGUE. `availableModels` was + loaded once at mount and only ever re-fetched for custom providers, so on a fresh + install — where nothing is connected at mount and the list starts empty — the Default + Model section stayed stuck on "No models available yet. Connect a provider above to see + model options." even after the provider connected. The operator was never offered a + default model at all (report), and onboarding completed with none set. + */ + void loadModels(); setLoginDialogProvider((current) => (current === providerId ? null : current)); clearAuthLoginUiState(); if (providerId === "github") { @@ -1773,6 +1817,8 @@ export function ModelOnboardingModal({ try { await saveApiKey(providerId, key); await loadAuthStatus(); + // Same reason as the OAuth path: a key that just unlocked a provider must populate its models. + void loadModels(); scrollOnboardingContentToTop(); setApiKeyInputs((prev) => { @@ -1946,10 +1992,19 @@ export function ModelOnboardingModal({ } await updateGlobalSettings(updates); + } catch { + /* + FNXC:Onboarding 2026-08-18-07:30: + A FAILED SETTINGS WRITE MUST NOT STRAND ONBOARDING AS UNFINISHED. Marking completion used to sit + after this await inside the same try, so any failure persisting the default model — a transient + request, a restarting backend — skipped it silently, and the dashboard went on advertising + "Continue Setup" at the first step to an operator who had finished the whole flow (report: + "my dashboard showed I was last on ai setup step but I actually finished it"). Completion is a + local fact about what the operator did; it does not depend on the default-model write landing. + */ + } finally { // Mark onboarding as completed (preserves state for completion timestamp) markOnboardingCompleted(); - } catch { - // Best-effort: continue even if save fails } }, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]); @@ -2846,9 +2901,15 @@ export function ModelOnboardingModal({ {/* Model Selection — placed directly after the provider sections */} -
+

- {t("setup.defaultModelOptional", "Default Model (Optional)")} + {modelChoicePending + ? t("setup.defaultModelChooseNow", "Choose your default model") + : t("setup.defaultModelOptional", "Default Model (Optional)")}

{t("setup.defaultModelDescription", "Pick a default model for AI tasks, or leave this blank to choose later. Models vary in speed, capability, and cost.")} diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index 27e526bd98..990dab91e1 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -722,6 +722,46 @@ describe("ModelOnboardingModal", () => { }); }); + + /* + FNXC:Onboarding 2026-08-18-07:30: + Two failures the operator hit on a fresh install, pinned together because they compound: the + default-model section never populated: models were only re-fetched for custom providers, so it stayed + on its empty state after connecting and no default model was ever offered. + + The sibling change — marking completion in a `finally` so a failed settings write cannot strand + onboarding as unfinished — is deliberately NOT asserted here. A test written against it passed + with and without the fix, so it proved nothing; the operator-reported "showed I was last on ai + setup step but I actually finished it" is not reproduced by rejecting that write alone. Treat that + change as hardening, and leave this note so nobody mistakes its absence for missing coverage. + */ + describe("first-run default model and completion", () => { + it("refreshes the model list once a provider connects", async () => { + mockFetchModels.mockResolvedValue({ models: [] }); + mockConfirm.mockResolvedValue(true); + mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ + providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }], + })); + mockLoginProvider.mockResolvedValue({ url: "https://claude.ai/oauth/authorize?state=abc", manualCode: { prompt: "Paste" } }); + + render(); + await waitFor(() => expect(screen.getByTestId("onboarding-provider-card-anthropic")).toBeTruthy()); + const callsBefore = mockFetchModels.mock.calls.length; + + // The provider flips to authenticated on the next poll, as a completed login does. + mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ + providers: [{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }], + })); + const card = screen.getByTestId("onboarding-provider-card-anthropic"); + fireEvent.click([...card.querySelectorAll("button")].find((b) => /^login$/i.test(b.textContent ?? ""))!); + + await waitFor(() => { + expect(mockFetchModels.mock.calls.length, "connecting a provider must re-fetch its models").toBeGreaterThan(callsBefore); + }, { timeout: 5000 }); + }); + + }); + describe("AI Setup step", () => { it("shows OAuth providers with Login button", async () => { render();