fix(onboarding): offer a default model as soon as a provider connects

Operator on a fresh install was never prompted to pick a default model, and the
dashboard later advertised "Continue Setup" at the AI Setup step even though
they had finished the flow.

availableModels was loaded once at mount and re-fetched only when a CUSTOM
provider was added. On a fresh install nothing is connected at mount, so the
list starts empty and the Default Model section renders its empty state — and
after an OAuth login or an API-key save it was never refreshed, so it kept
saying "No models available yet. Connect a provider above to see model options."
while a provider sat connected right above it.

Both connect paths now refresh the catalogue. Once a provider is connected and
no model is chosen, the section retitles from "Default Model (Optional)" to
"Choose your default model" and scrolls itself into view once — the moment it
becomes actionable is exactly the moment it is off screen, below every provider
card. It stays skippable.

scrollIntoView is called only when it exists: JSDOM does not implement it, and a
cosmetic nudge must never throw out of an effect and take the modal down (it did
— 24 suites failed until guarded).

Completion is additionally marked in a `finally`, so a failed default-model
settings write cannot leave onboarding recorded as unfinished. That part is
hardening, NOT a proven repro: a test written against it passed with and without
the change, so it was removed rather than kept as false coverage, and the reason
is recorded in the suite.

Also folds in the container's default git identity, which the merge investigation
needed (see the follow-up commit for the per-agent identity work).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-17 23:45:40 -07:00
parent 189087adf8
commit 889728bd20
4 changed files with 128 additions and 4 deletions

View File

@@ -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.

View File

@@ -159,6 +159,22 @@ RUN chown node:node /app \
USER node 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 WORKDIR /workspace
EXPOSE 4040 EXPOSE 4040

View File

@@ -827,6 +827,18 @@ export function ModelOnboardingModal({
const [loginErrors, setLoginErrors] = useState<Record<string, string>>({}); const [loginErrors, setLoginErrors] = useState<Record<string, string>>({});
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]); const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(""); const [selectedModel, setSelectedModel] = useState<string>("");
/*
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<HTMLDivElement | null>(null);
const modelPromptNudgedRef = useRef(false);
const [modelChoicePending, setModelChoicePending] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({}); const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({}); const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
@@ -1378,6 +1390,28 @@ export function ModelOnboardingModal({
}, [agentDraft, handleNext, projectId, t]); }, [agentDraft, handleNext, projectId, t]);
// OAuth login handler // 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( const handleLogin = useCallback(
async (providerId: string) => { async (providerId: string) => {
const provider = authProviders.find((entry) => entry.id === providerId); const provider = authProviders.find((entry) => entry.id === providerId);
@@ -1536,6 +1570,16 @@ export function ModelOnboardingModal({
} }
setAuthActionInProgress(null); setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" })); 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)); setLoginDialogProvider((current) => (current === providerId ? null : current));
clearAuthLoginUiState(); clearAuthLoginUiState();
if (providerId === "github") { if (providerId === "github") {
@@ -1773,6 +1817,8 @@ export function ModelOnboardingModal({
try { try {
await saveApiKey(providerId, key); await saveApiKey(providerId, key);
await loadAuthStatus(); await loadAuthStatus();
// Same reason as the OAuth path: a key that just unlocked a provider must populate its models.
void loadModels();
scrollOnboardingContentToTop(); scrollOnboardingContentToTop();
setApiKeyInputs((prev) => { setApiKeyInputs((prev) => {
@@ -1946,10 +1992,19 @@ export function ModelOnboardingModal({
} }
await updateGlobalSettings(updates); 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) // Mark onboarding as completed (preserves state for completion timestamp)
markOnboardingCompleted(); markOnboardingCompleted();
} catch {
// Best-effort: continue even if save fails
} }
}, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]); }, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]);
@@ -2846,9 +2901,15 @@ export function ModelOnboardingModal({
</section> </section>
{/* Model Selection — placed directly after the provider sections */} {/* Model Selection — placed directly after the provider sections */}
<div className="onboarding-model-section"> <div
ref={modelSectionRef}
className={`onboarding-model-section${modelChoicePending ? " onboarding-model-section--pending" : ""}`}
data-testid="onboarding-model-section"
>
<h3 className="onboarding-section-title"> <h3 className="onboarding-section-title">
{t("setup.defaultModelOptional", "Default Model (Optional)")} {modelChoicePending
? t("setup.defaultModelChooseNow", "Choose your default model")
: t("setup.defaultModelOptional", "Default Model (Optional)")}
</h3> </h3>
<p className="model-onboarding-description"> <p className="model-onboarding-description">
{t("setup.defaultModelDescription", "Pick a default model for AI tasks, or leave this blank to choose later. Models vary in speed, capability, and cost.")} {t("setup.defaultModelDescription", "Pick a default model for AI tasks, or leave this blank to choose later. Models vary in speed, capability, and cost.")}

View File

@@ -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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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", () => { describe("AI Setup step", () => {
it("shows OAuth providers with Login button", async () => { it("shows OAuth providers with Login button", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />); render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);