fix(dashboard): polish first-run setup wizard (AI step, GitHub step, project step)

- AI Setup: connected providers now render first; 'Skip for now' hides
  once a provider is connected; footer buttons share one size.
- GitHub step: state-driven (no install/pitch content when connected),
  one type scale, real <code> literals, per-OS gh instructions behind a
  disclosure, single skip affordance; gh/OAuth status revalidates on
  window focus and OAuth relogin so later steps stop showing stale
  'not connected'.
- Project step: onboarding-driven opens keep 'Step 3 of 5' context;
  runtime-node picker no longer lists 'Local node' and 'local (local)'
  duplicates and hides itself when only the local node exists;
  isolation-mode cards no longer render a stretched native radio or
  uppercased descriptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-10 16:08:04 -07:00
parent e4d404ea74
commit 2cfeb74407
18 changed files with 693 additions and 175 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Overhaul first-run setup: connected providers on top, state-driven GitHub step, fixed isolation-mode radios, deduped runtime-node picker.
category: fix
dev: New setupWizardNodes.ts (getSelectableRuntimeNodes/shouldShowRuntimeNodeSelector) shared by SetupWizardModal and SetupProjectForm; GitHub status revalidates on window focus and OAUTH_RELOGIN_SUCCESS_EVENT; 4 new i18n keys.

View File

@@ -667,20 +667,31 @@
gap: var(--space-lg);
}
/*
FNXC:Onboarding 2026-07-10-10:45:
First-run review: the GitHub step mixed tiny bold micro-headers with helper-text bullets in one flat
list. The with/without comparison now renders as two labeled groups on a single consistent type scale
(font-size-sm headings + font-size-sm items), side by side on desktop and stacked on mobile.
*/
.onboarding-feature-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-lg);
padding: var(--space-md);
border-radius: var(--radius-md);
background: var(--surface);
border: 1px solid var(--border);
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
color: var(--text-muted);
line-height: 1.5;
}
.onboarding-feature-list ul {
.onboarding-feature-list__group h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text);
}
.onboarding-feature-list__group ul {
list-style: none;
padding: 0;
margin: 0;
@@ -689,33 +700,19 @@
gap: var(--space-xs);
}
.onboarding-feature-list .onboarding-feature-list-heading strong {
display: block;
font-size: var(--space-md);
font-weight: 600;
color: var(--text);
margin-top: var(--space-sm);
}
.onboarding-feature-list .onboarding-feature-list-heading:first-child strong {
margin-top: 0;
}
.onboarding-feature-list li:not(.onboarding-feature-list-heading) {
.onboarding-feature-list__group li {
font-size: var(--font-size-sm);
color: var(--text-muted);
padding-left: var(--space-md);
position: relative;
}
.onboarding-feature-list li:not(.onboarding-feature-list-heading)::before {
.onboarding-feature-list__group li::before {
content: "•";
position: absolute;
left: 0;
}
.onboarding-feature-list .onboarding-feature-list-item--with-github {
color: var(--text-muted);
}
/* === Onboarding GitHub Step === */
.onboarding-github-git-prerequisite {
display: flex;
@@ -875,6 +872,24 @@
font-family: var(--font-mono);
}
/*
FNXC:Onboarding 2026-07-10-10:50:
Inline command fragments (`gh`, `brew install gh`, `sudo apt install git`) in git/gh install guidance
render as real <code> elements (see renderWithInlineCode) instead of raw backtick literals.
*/
.onboarding-github-setup-card p code,
.onboarding-github-setup-card li code,
.onboarding-github-git-prerequisite p code,
.onboarding-github-git-prerequisite li code {
padding: 1px var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-raised);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.9em;
}
/* === GitHub Connection Status Feedback (FN-1934) === */
.onboarding-github-feedback {
margin-top: var(--space-sm);
@@ -1271,12 +1286,25 @@
background: var(--surface);
}
.model-onboarding-footer .btn-primary {
display: flex;
/*
FNXC:Onboarding 2026-07-10-10:55:
First-run review: footer buttons had wildly different sizes ("Next →" much larger than the skip
buttons). Every footer control shares the same 38px height and 14px type; the primary action keeps
its filled emphasis and wider padding only.
*/
.model-onboarding-footer .btn,
.model-onboarding-footer .onboarding-skip-step-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 10px 24px;
min-height: 38px;
padding: 8px 16px;
font-size: 14px;
}
.model-onboarding-footer .btn-primary {
padding: 8px 24px;
font-weight: 600;
}
@@ -1526,6 +1554,12 @@
gap: var(--space-sm);
}
/* FNXC:Onboarding 2026-07-10-10:45: stack the with/without GitHub comparison on phones. */
.onboarding-feature-list {
grid-template-columns: 1fr;
gap: var(--space-md);
}
.model-onboarding-footer .btn-primary {
flex: 1;
min-width: 0;

View File

@@ -1,6 +1,6 @@
import "./ModelOnboardingModal.css";
import "./SetupWizardModal.css";
import { lazy, Suspense, useState, useEffect, useCallback, useRef, useMemo, type KeyboardEvent } from "react";
import { lazy, Suspense, useState, useEffect, useCallback, useRef, useMemo, type KeyboardEvent, type ReactNode } from "react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus, Sparkles, UserRound } from "lucide-react";
import { getErrorMessage, type Task } from "@fusion/core";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, CustomProvider, CustomProviderConfig, OAuthDeviceCodeInfo } from "../api";
@@ -228,6 +228,23 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
const getManualCodeLoginWarningMessage = (providerName: string) =>
`After you sign in with ${providerName}, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?`;
/*
FNXC:Onboarding 2026-07-10-10:05:
Install-guidance i18n strings (git/gh CLI) contain backtick-quoted commands like `gh` and `brew install gh`.
Previously those backticks rendered as literal characters in plain text (first-run review called this out as slop).
Render backtick spans as real <code> elements so commands are visually distinct, without splitting every
translated string into prefix/suffix keys (all existing locale catalogs keep working unchanged).
*/
function renderWithInlineCode(text: string): ReactNode {
const parts = text.split("`");
if (parts.length < 3) {
return text;
}
return parts.map((part, index) =>
index % 2 === 1 ? <code key={index}>{part}</code> : <span key={index}>{part}</span>,
);
}
function getProviderDisplayName(providerId: string): string {
if (PROVIDER_DISPLAY_NAMES[providerId]) {
return PROVIDER_DISPLAY_NAMES[providerId];
@@ -1006,7 +1023,6 @@ export function ModelOnboardingModal({
const hasGithubProvider = githubActionState.oauth.providerAvailable;
const isGithubAuthenticated = githubActionState.oauth.authenticated;
const isGithubLoginInProgress = githubActionState.oauth.loginInProgress;
const isGithubCliAuthenticated = githubActionState.ghCli.authenticated;
const gitInstallUrl = gitCliStatus?.installUrl ?? GIT_INSTALL_URL;
const gitVersionLabel = gitCliStatus?.version
? t("setup.gitPrerequisiteInstalledVersion", "Git is installed on the Fusion host ({{version}}).", { version: gitCliStatus.version })
@@ -1121,6 +1137,27 @@ export function ModelOnboardingModal({
);
}, [loadAuthStatus, loadModels, loadGlobalSettings]);
/*
FNXC:Onboarding 2026-07-10-10:10:
Bug from first-run review: connecting GitHub mid-session (e.g. running `gh auth login` in a terminal,
or completing an OAuth login from another surface) left later wizard steps (Agent / First Task readiness
summary) showing GitHub as "not connected" because provider/gh-CLI status was only fetched once and only
re-fetched when returning to the AI Setup step. Revalidate the cached auth/gh/git status whenever the
window regains focus (the user just came back from a terminal or an OAuth tab) and whenever any surface
broadcasts a successful OAuth re-login, so every step reflects the current connection state.
*/
useEffect(() => {
const refreshAuthStatus = () => {
void loadAuthStatus();
};
window.addEventListener("focus", refreshAuthStatus);
window.addEventListener(OAUTH_RELOGIN_SUCCESS_EVENT, refreshAuthStatus);
return () => {
window.removeEventListener("focus", refreshAuthStatus);
window.removeEventListener(OAUTH_RELOGIN_SUCCESS_EVENT, refreshAuthStatus);
};
}, [loadAuthStatus]);
// Restore login outcomes from persisted state on mount
useEffect(() => {
const persistedStepData = getStepData("ai-setup");
@@ -2147,8 +2184,19 @@ export function ModelOnboardingModal({
const visibleOrderedAiProviders = orderedAiProviders.filter(
(provider) => !(provider.id === "anthropic" && hasSeparatedAnthropicProvider),
);
/*
FNXC:Onboarding 2026-07-10-10:15:
First-run review: after connecting a provider the "Connected providers" section rendered BELOW the
quick-start cards and the long provider list, so users could not see what was already connected without
scrolling. ALL connected providers (quick-start and advanced alike) now render in a single "Connected
providers" section at the TOP of the AI Setup step, and the quick-start list only offers providers that
still need connecting. The "N of M providers connected" summary above is unchanged.
*/
const connectedVisibleProviders = visibleOrderedAiProviders.filter(
(provider) => provider.authenticated,
);
const quickStartProviders = visibleOrderedAiProviders
.filter((provider) => quickStartSet.has(provider.id))
.filter((provider) => quickStartSet.has(provider.id) && !provider.authenticated)
.sort((a, b) => {
const rankA = QUICK_START_PROVIDER_IDS.indexOf(a.id as (typeof QUICK_START_PROVIDER_IDS)[number]);
const rankB = QUICK_START_PROVIDER_IDS.indexOf(b.id as (typeof QUICK_START_PROVIDER_IDS)[number]);
@@ -2157,9 +2205,6 @@ export function ModelOnboardingModal({
}
return compareOnboardingProviders(a, b);
});
const connectedNonQuickStartProviders = visibleOrderedAiProviders.filter(
(provider) => provider.authenticated && !quickStartSet.has(provider.id),
);
const advancedProviders = visibleOrderedAiProviders.filter(
(provider) => !provider.authenticated && !quickStartSet.has(provider.id),
);
@@ -2625,12 +2670,30 @@ export function ModelOnboardingModal({
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
{/*
FNXC:Onboarding 2026-07-10-10:15:
Connected providers render FIRST so that reopening the step (or returning after an
OAuth login) immediately shows what is already connected without scrolling.
*/}
{connectedVisibleProviders.length > 0 && (
<section className="onboarding-provider-section" data-testid="onboarding-connected-providers">
<h3 className="onboarding-section-title">{t("setup.connectedProviders", "Connected providers")}</h3>
<div className="model-onboarding-providers">
{connectedVisibleProviders.map((provider) => renderAiProviderCard(provider))}
</div>
</section>
)}
<section className="onboarding-provider-section" data-testid="onboarding-quick-start-providers">
<h3 className="onboarding-section-title">{t("setup.quickStartProviders", "Quick start providers")}</h3>
{quickStartProviders.length > 0 ? (
<div className="model-onboarding-providers">
{quickStartProviders.map((provider) => renderAiProviderCard(provider))}
</div>
) : connectedVisibleProviders.length > 0 ? (
<p className="onboarding-helper-text">
{t("setup.quickStartAllConnected", "All quick-start providers are already connected.")}
</p>
) : (
<p className="onboarding-helper-text">
{t("setup.noQuickStartProviders", "No quick-start providers are available in this environment.")}
@@ -2680,16 +2743,7 @@ export function ModelOnboardingModal({
</OnboardingDisclosure>
</section>
{connectedNonQuickStartProviders.length > 0 && (
<section className="onboarding-provider-section" data-testid="onboarding-connected-providers">
<h3 className="onboarding-section-title">{t("setup.connectedProviders", "Connected providers")}</h3>
<div className="model-onboarding-providers">
{connectedNonQuickStartProviders.map((provider) => renderAiProviderCard(provider))}
</div>
</section>
)}
{/* Model Selection — placed directly after the authenticated provider list */}
{/* Model Selection — placed directly after the provider sections */}
<div className="onboarding-model-section">
<h3 className="onboarding-section-title">
{t("setup.defaultModelOptional", "Default Model (Optional)")}
@@ -2793,13 +2847,20 @@ export function ModelOnboardingModal({
<p>{gitVersionLabel}</p>
) : (
<>
{/*
FNXC:Onboarding 2026-07-10-10:20:
First-run review flagged raw backtick literals rendering as plain text.
Commands in these strings now render as real <code> elements via renderWithInlineCode.
Missing git is a hard blocker for project setup, so its per-OS instructions stay
expanded (unlike the optional gh CLI guidance which is collapsed behind a disclosure).
*/}
<p>
{t("setup.gitPrerequisiteMissingBody", "Fusion could not find `git` on the server host running Fusion. Install Git there before cloning repositories, initializing projects, or registering Git-backed workspaces.")}
{renderWithInlineCode(t("setup.gitPrerequisiteMissingBody", "Fusion could not find `git` on the server host running Fusion. Install Git there before cloning repositories, initializing projects, or registering Git-backed workspaces."))}
</p>
<ul className="onboarding-github-git-install-list">
<li>{t("setup.gitPrerequisiteMac", "macOS: install Xcode Command Line Tools with `xcode-select --install`, Homebrew with `brew install git`, or the Git installer.")}</li>
<li>{t("setup.gitPrerequisiteWindows", "Windows: install Git for Windows and restart the Fusion host shell or service.")}</li>
<li>{t("setup.gitPrerequisiteLinux", "Linux: install with your package manager, for example `sudo apt install git`, `sudo dnf install git`, or `sudo pacman -S git`.")}</li>
<li>{renderWithInlineCode(t("setup.gitPrerequisiteMac", "macOS: install Xcode Command Line Tools with `xcode-select --install`, Homebrew with `brew install git`, or the Git installer."))}</li>
<li>{renderWithInlineCode(t("setup.gitPrerequisiteWindows", "Windows: install Git for Windows and restart the Fusion host shell or service."))}</li>
<li>{renderWithInlineCode(t("setup.gitPrerequisiteLinux", "Linux: install with your package manager, for example `sudo apt install git`, `sudo dnf install git`, or `sudo pacman -S git`."))}</li>
</ul>
<a href={gitInstallUrl} target="_blank" rel="noreferrer">
{t("setup.gitPrerequisiteInstallLink", "Open Git install downloads")}
@@ -2809,22 +2870,31 @@ export function ModelOnboardingModal({
</div>
)}
{/*
FNXC:Onboarding 2026-07-10-10:25:
First-run review called the GitHub step "slop-coded": it mixed tiny bold micro-headers,
bullet items, and helper-text sizes inside one flat list. The with/without comparison is
now two labeled groups on one consistent type scale (heading + plain list items), rendered
only while GitHub is NOT connected — a connected user does not need the sales pitch.
*/}
{!isGitHubReady && (
<div className="onboarding-feature-list">
<ul>
<li className="onboarding-feature-list-heading">
<strong>{t("setup.withoutGitHubHeading", "Without GitHub (available now):")}</strong>
</li>
<li className="onboarding-helper-text">{t("setup.withoutGitHub1", "Create tasks manually")}</li>
<li className="onboarding-helper-text">{t("setup.withoutGitHub2", "Describe work for AI agents")}</li>
<li className="onboarding-helper-text">{t("setup.withoutGitHub3", "Track progress on the board")}</li>
<li className="onboarding-feature-list-heading">
<strong>{t("setup.withGitHubHeading", "With GitHub (after connecting):")}</strong>
</li>
<li className="onboarding-helper-text onboarding-feature-list-item--with-github">{t("setup.withGitHub1", "Import issues as tasks")}</li>
<li className="onboarding-helper-text onboarding-feature-list-item--with-github">{t("setup.withGitHub2", "Sync pull request status")}</li>
<li className="onboarding-helper-text onboarding-feature-list-item--with-github">{t("setup.withGitHub3", "Link code changes to tasks")}</li>
</ul>
<div className="onboarding-feature-list__group">
<h4>{t("setup.withoutGitHubHeading", "Without GitHub (available now)")}</h4>
<ul>
<li>{t("setup.withoutGitHub1", "Create tasks manually")}</li>
<li>{t("setup.withoutGitHub2", "Describe work for AI agents")}</li>
<li>{t("setup.withoutGitHub3", "Track progress on the board")}</li>
</ul>
</div>
<div className="onboarding-feature-list__group">
<h4>{t("setup.withGitHubHeading", "With GitHub (after connecting)")}</h4>
<ul>
<li>{t("setup.withGitHub1", "Import issues as tasks")}</li>
<li>{t("setup.withGitHub2", "Sync pull request status")}</li>
<li>{t("setup.withGitHub3", "Link code changes to tasks")}</li>
</ul>
</div>
</div>
)}
@@ -2849,27 +2919,36 @@ export function ModelOnboardingModal({
GitHub onboarding must give users in-flow paths for both supported auth modes instead of Settings-only explanatory copy.
The dashboard can start OAuth when the GitHub provider exists; `gh` CLI setup remains explicit host-side guidance because Fusion does not install third-party binaries automatically.
*/}
{!isGithubCliAuthenticated && githubActionState.ghCli.state === "missing" && (
{/*
FNXC:Onboarding 2026-07-10-10:30:
State-driven GitHub step (first-run review): the gh CLI cards only render while GitHub is
NOT ready overall — an OAuth-connected user must never be told to install the GitHub CLI.
The per-OS install wall of text is collapsed behind a single disclosure; the card leads
with one sentence and commands render as <code> instead of raw backtick literals.
*/}
{!isGitHubReady && githubActionState.ghCli.state === "missing" && (
<div className="onboarding-github-setup-card onboarding-github-setup-card--warning" data-testid="onboarding-gh-cli-install-card" role="status">
<div className="onboarding-github-setup-card__heading">
<ProviderIcon provider="github" size="sm" />
<strong>{t("setup.githubCliInstallTitle", "Install GitHub CLI")}</strong>
</div>
<p>
{t("setup.githubCliInstallBody", "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.")}
{renderWithInlineCode(t("setup.githubCliInstallBody", "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication."))}
</p>
<ul className="onboarding-github-setup-list">
<li>{t("setup.githubCliInstallMac", "macOS: `brew install gh` or download the installer from GitHub CLI releases.")}</li>
<li>{t("setup.githubCliInstallWindows", "Windows: install GitHub CLI with WinGet, Chocolatey, or the GitHub CLI installer, then restart the Fusion host shell or service.")}</li>
<li>{t("setup.githubCliInstallLinux", "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.")}</li>
</ul>
<a className="btn btn-sm" href={GH_CLI_INSTALL_URL} target="_blank" rel="noreferrer">
{t("setup.githubCliInstallLink", "Open GitHub CLI releases")}
</a>
<OnboardingDisclosure summary={t("setup.githubCliInstallShowInstructions", "Show install instructions")}>
<ul className="onboarding-github-setup-list">
<li>{renderWithInlineCode(t("setup.githubCliInstallMac", "macOS: `brew install gh` or download the installer from GitHub CLI releases."))}</li>
<li>{renderWithInlineCode(t("setup.githubCliInstallWindows", "Windows: install GitHub CLI with WinGet, Chocolatey, or the GitHub CLI installer, then restart the Fusion host shell or service."))}</li>
<li>{renderWithInlineCode(t("setup.githubCliInstallLinux", "Linux: install `gh` with your distribution package manager or the packages from cli.github.com."))}</li>
</ul>
<a className="btn btn-sm" href={GH_CLI_INSTALL_URL} target="_blank" rel="noreferrer">
{t("setup.githubCliInstallLink", "Open GitHub CLI releases")}
</a>
</OnboardingDisclosure>
</div>
)}
{!isGithubCliAuthenticated && githubActionState.ghCli.state === "unauthenticated" && (
{!isGitHubReady && githubActionState.ghCli.state === "unauthenticated" && (
<div className="onboarding-github-setup-card onboarding-github-setup-card--info" data-testid="onboarding-gh-cli-auth-card" role="status">
<div className="onboarding-github-setup-card__heading">
<ProviderIcon provider="github" size="sm" />
@@ -2898,32 +2977,40 @@ export function ModelOnboardingModal({
<ProviderIcon provider="github" size="lg" />
</div>
{isGitHubReadyViaCli ? (
<p>
{t("setup.githubCliAuthNote", "GitHub CLI is already authenticated, so imports and PR tracking work now. OAuth from the dashboard is optional and only controls dashboard-managed connect/disconnect.")}
</p>
<>
<p>
{t("setup.githubCliAuthNote", "GitHub CLI is already authenticated, so imports and PR tracking work now. OAuth from the dashboard is optional and only controls dashboard-managed connect/disconnect.")}
</p>
<div className="model-onboarding-github-optional__actions">
<button
className="btn btn-primary btn-sm"
/*
* FNXC:Onboarding 2026-07-03-08:00:
* Advance via handleNext (not a raw setStep) so the GitHub step's status is
* recorded. When GitHub is ready via the gh CLI, "Continue with gh CLI auth"
* must COMPLETE the step (handleNext) — previously it jumped to project-setup
* without adding "github" to completedSteps, so step 2 never checked off.
*/
onClick={handleNext}
>
{t("setup.continueWithGhCli", "Continue with gh CLI auth →")}
</button>
</div>
</>
) : (
/*
* FNXC:Onboarding 2026-07-10-10:35:
* First-run review: the step offered THREE overlapping escape hatches at once —
* an in-body "Continue without GitHub →" CTA plus footer "Skip GitHub →" plus
* "Next →". The in-body button is removed entirely; the footer "Skip GitHub →"
* is the single skip affordance. The explanatory sentence (and its wrapper) stays,
* and its former actions <div> is dropped with it so no empty button shell remains
* on desktop or mobile.
*/
<p>
{t("setup.githubOauthUnavailable", "Dashboard GitHub OAuth is not configured on this Fusion host. You can still continue without GitHub, or use the GitHub CLI setup guidance above when available.")}
{t("setup.githubOauthUnavailable", "Dashboard GitHub OAuth is not configured on this Fusion host. You can still skip GitHub below, or use the GitHub CLI setup guidance above when available.")}
</p>
)}
<div className="model-onboarding-github-optional__actions">
<button
className="btn btn-primary btn-sm"
/*
* FNXC:Onboarding 2026-07-03-08:00:
* Advance via handleNext/handleSkip (not a raw setStep) so the GitHub step's
* status is recorded. When GitHub is ready via the gh CLI, "Continue with gh CLI
* auth" must COMPLETE the step (handleNext) — previously it jumped to project-setup
* without adding "github" to completedSteps, so step 2 never checked off. When
* GitHub isn't connected, "Continue without GitHub" is a SKIP (handleSkip).
*/
onClick={isGitHubReadyViaCli ? handleNext : handleSkip}
>
{isGitHubReadyViaCli
? t("setup.continueWithGhCli", "Continue with gh CLI auth →")
: t("setup.continueWithoutGitHub", "Continue without GitHub →")}
</button>
</div>
</div>
) : (
<>
@@ -3358,13 +3445,23 @@ export function ModelOnboardingModal({
</a>
{step === "ai-setup" && (
<>
<button
className="btn btn-sm"
onClick={handleDismiss}
disabled={saving}
>
{t("setup.skipForNow", "Skip for now")}
</button>
{/*
FNXC:Onboarding 2026-07-10-10:40:
First-run review: the AI Setup footer showed BOTH "Skip for now" (dismiss onboarding) and
"Skip setup →" (skip this step) next to "Next →". Once at least one AI provider is
connected the user has effectively completed this step, so "Skip for now" is redundant
noise and is hidden; "Skip setup →" and "Next →" remain. No wrapper/aria-label is left
behind — the button subtree is omitted entirely (desktop and mobile share this markup).
*/}
{!hasAiProvider && (
<button
className="btn btn-sm"
onClick={handleDismiss}
disabled={saving}
>
{t("setup.skipForNow", "Skip for now")}
</button>
)}
<button className="onboarding-skip-step-link" onClick={handleSkip}>
{t("setup.skipSetup", "Skip setup →")}
</button>

View File

@@ -4,6 +4,7 @@ import { Check, Loader2 } from "lucide-react";
import { validateProjectPath, validateProjectName, suggestProjectName } from "../utils/projectDetection";
import type { ProjectCreateInput, NodeInfo } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import { getSelectableRuntimeNodes, shouldShowRuntimeNodeSelector } from "./setupWizardNodes";
export interface SetupProjectFormProps {
/** Called when the form is submitted with valid data */
@@ -119,24 +120,34 @@ export function SetupProjectForm({
return (
<form onSubmit={handleSubmit} className="setup-project-form">
{/* Node selector */}
<div className="form-group">
<div className="project-node-selector">
<span className="project-node-selector__label">{t("setup.runtimeNode", "Runtime Node")}</span>
<select
value={nodeId}
onChange={(e) => setNodeId(e.target.value)}
disabled={isSubmitting}
>
<option value="">{t("setup.localNode", "Local node")}</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
{/*
FNXC:SetupWizard 2026-07-10-11:00:
Same Runtime Node dedupe as SetupWizardModal (Surface Enumeration for the duplicate
"Local node" / "local (local)" option bug): local-type node records never render as
options and the selector is hidden when only the local machine exists.
*/}
{shouldShowRuntimeNodeSelector(nodes) && (
<div className="form-group">
<div className="project-node-selector">
<span className="project-node-selector__label">{t("setup.runtimeNode", "Runtime Node")}</span>
<select
value={nodeId}
onChange={(e) => setNodeId(e.target.value)}
disabled={isSubmitting}
>
<option value="">{t("setup.localNode", "Local node")}</option>
{getSelectableRuntimeNodes(nodes).map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
</div>
<p className="form-hint">
{t("setup.runtimeNodeHint", "A runtime node is the machine where this project's tasks run. \"Local node\" is this computer; pick a remote node to run tasks elsewhere.")}
</p>
</div>
</div>
)}
{/* Path input */}
<div className="form-group">

View File

@@ -106,6 +106,19 @@
line-height: 1.1;
}
/*
FNXC:SetupWizard 2026-07-10-11:05:
Eyebrow shown when this wizard runs as the onboarding "Project" step sub-flow, so the user keeps the
"Step 3 of 5" context instead of feeling ejected into an unrelated modal.
*/
.setup-wizard-step-context {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.setup-wizard-content {
padding: var(--space-md) var(--space-xl);
flex: 1 1 auto;
@@ -250,7 +263,13 @@
margin-bottom: var(--space-xs);
}
.setup-wizard-manual .form-group input,
/*
FNXC:SetupWizard 2026-07-10-11:10:
Exclude radios/checkboxes from the full-width text-input styling. This rule previously matched the
isolation-mode radio inputs too, stretching the native radio into a full-width accent-colored bar
across the option card on click/focus (first-run review bug).
*/
.setup-wizard-manual .form-group input:not([type="radio"]):not([type="checkbox"]),
.setup-wizard-manual .form-group select {
width: 100%;
padding: 10px 12px;
@@ -262,7 +281,7 @@
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.setup-wizard-manual .form-group input:focus,
.setup-wizard-manual .form-group input:not([type="radio"]):not([type="checkbox"]):focus,
.setup-wizard-manual .form-group select:focus {
outline: none;
border-color: var(--todo);
@@ -560,7 +579,17 @@
flex-direction: column;
}
/*
FNXC:SetupWizard 2026-07-10-11:10:
Isolation-mode option cards ARE the selectable control (first-run review): the native radio used to
float centered in the card and stretched into a full-width orange bar on focus/click. The radio is now
visually hidden (kept in the DOM for form semantics and keyboard selection), the card shows the
selected state via border/ring, and focus-visible on the hidden radio draws the focus ring on the card.
Descriptions render in sentence case — the global ".form-group label" uppercase transform is
explicitly neutralized here because each option card is itself a <label> inside a .form-group.
*/
.setup-wizard-isolation-option {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-md);
@@ -569,6 +598,8 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast);
@@ -586,8 +617,20 @@
}
.setup-wizard-isolation-option input[type="radio"] {
margin-top: 2px;
accent-color: var(--todo);
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
appearance: none;
}
.setup-wizard-isolation-option:has(input[type="radio"]:focus-visible) {
border-color: var(--todo);
box-shadow: var(--focus-ring-strong);
}
.setup-wizard-isolation-option-content {
@@ -649,12 +692,23 @@
background: var(--surface);
}
.setup-wizard-footer .btn-primary {
/*
FNXC:SetupWizard 2026-07-10-10:55:
Match ModelOnboarding footer sizing: all footer controls share the same height/type scale; the
primary action keeps its filled emphasis and wider padding only.
*/
.setup-wizard-footer .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 10px 24px;
min-height: 38px;
padding: 8px 16px;
font-size: 14px;
}
.setup-wizard-footer .btn-primary {
padding: 8px 24px;
font-weight: 600;
}

View File

@@ -20,6 +20,7 @@ function suggestTaskPrefixFromName(name: string): string {
return "FN";
}
import { useNodes } from "../hooks/useNodes";
import { getSelectableRuntimeNodes, shouldShowRuntimeNodeSelector } from "./setupWizardNodes";
import { AgentAvatar } from "./AgentAvatar";
import { ErrorBoundary } from "./ErrorBoundary";
import { AGENT_PRESETS, getPresetById } from "./agent-presets";
@@ -404,8 +405,24 @@ export function SetupWizardModal({
</svg>
<span className="setup-wizard-brand-name">{t("setup.brandName", "Fusion")}</span>
</div>
{/*
FNXC:SetupWizard 2026-07-10-11:05:
First-run review: opening project registration from the 5-step onboarding wizard (its
Project step) replaced the stepper chrome with a bare "Welcome to Fusion" modal, which read
as being thrown out of setup. When this wizard runs as that sub-flow (includeAgentStep is
false — see AppModals: it is only false while ModelOnboarding is driving), the header keeps
the onboarding context explicit: a "Step 3 of 5 — Project" eyebrow plus the onboarding
step's own title instead of the standalone welcome title.
*/}
{!includeAgentStep && state.step === "manual" && (
<span className="setup-wizard-step-context">
{t("setup.projectStepContext", "Step 3 of 5 — Project · Fusion setup")}
</span>
)}
<h2 id="wizard-title" className="setup-wizard-title">
{state.step === "manual" && t("setup.welcomeToFusion", "Welcome to Fusion")}
{state.step === "manual" && (includeAgentStep
? t("setup.welcomeToFusion", "Welcome to Fusion")
: t("setup.titleSetUpProject", "Set Up Your Project"))}
{state.step === "agent" && t("setup.firstAgentTitle", "Create your first agent")}
{state.step === "complete" && t("setup.setupCompleteTitle", "Setup Complete!")}
</h2>
@@ -614,26 +631,49 @@ export function SetupWizardModal({
</button>
{showAdvancedSettings && (
<div className="setup-wizard-advanced-panel">
<div className="form-group">
<div className="project-node-selector">
<span className="project-node-selector__label">{t("setup.runtimeNode", "Runtime Node")}</span>
<select
value={state.manualNodeId}
onChange={(e) => setState((prev) => ({ ...prev, manualNodeId: e.target.value }))}
disabled={nodesLoading || state.isRegistering}
>
<option value="">{t("setup.localNode", "Local node")}</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
{/*
FNXC:SetupWizard 2026-07-10-11:00:
Runtime Node dedupe (first-run review): registered local-type node records used to
render as a second "local (local)" option next to the built-in "Local node" default.
Local-type records are filtered out (see setupWizardNodes.ts) and the whole selector
is hidden when only the local machine is available, with a plain-language description
of what a runtime node is when the choice does exist.
*/}
{shouldShowRuntimeNodeSelector(nodes) && (
<div className="form-group">
<div className="project-node-selector">
<span className="project-node-selector__label">{t("setup.runtimeNode", "Runtime Node")}</span>
<select
value={state.manualNodeId}
onChange={(e) => setState((prev) => ({ ...prev, manualNodeId: e.target.value }))}
disabled={nodesLoading || state.isRegistering}
>
<option value="">{t("setup.localNode", "Local node")}</option>
{getSelectableRuntimeNodes(nodes).map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
</div>
<p className="form-hint">
{t("setup.runtimeNodeHint", "A runtime node is the machine where this project's tasks run. \"Local node\" is this computer; pick a remote node to run tasks elsewhere.")}
</p>
</div>
</div>
)}
<div className="form-group">
<label>{t("setup.isolationMode", "Isolation Mode")}</label>
{/*
FNXC:SetupWizard 2026-07-10-11:10:
First-run review: the isolation-mode cards rendered a raw radio dot floating in the
card, and clicking/focusing stretched the native radio into a full-width accent bar
(a global ".form-group input { width: 100% }" rule hit the radio). The whole card is
the selectable control: the radio input is visually hidden (kept for semantics and
keyboard toggling), selection/focus is shown via the card's border/ring, and the
descriptions must read in sentence case — the global ".form-group label" uppercase
transform is neutralized for these option cards. See SetupWizardModal.css.
*/}
<div className="setup-wizard-isolation-options">
<label
className={`setup-wizard-isolation-option${state.manualIsolationMode === "in-process" ? " selected" : ""}`}

View File

@@ -338,6 +338,27 @@ describe("ModelOnboardingModal", () => {
});
});
it("hides the redundant Skip for now on AI Setup once a provider is connected", async () => {
// FNXC:Onboarding 2026-07-10-10:40: with a connected provider the user has effectively
// completed the step, so only "Skip setup →" and "Next →" remain in the footer.
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next →" })).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Skip for now" })).toBeNull();
expect(screen.getByRole("button", { name: "Skip setup →" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Next →" })).toBeTruthy();
});
// FNXC:Onboarding 2026-07-03: the "Research runs require provider credentials and an enabled
// Research View" note was intentionally removed from onboarding at the operator's request (it
// belongs in Settings, not first-run), so the assertion that it renders no longer applies.
@@ -688,6 +709,34 @@ describe("ModelOnboardingModal", () => {
expect(screen.queryByTestId("onboarding-advanced-provider-settings")).toBeNull();
});
it("surfaces connected quick-start providers in the Connected section at the top of the step", async () => {
/*
FNXC:Onboarding 2026-07-10-10:15:
After connecting a provider the user must immediately see it on step open, without scrolling:
ALL connected providers render in "Connected providers" ABOVE quick start, and the connected
provider leaves the quick-start list.
*/
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
const connectedSection = await screen.findByTestId("onboarding-connected-providers");
expect(getProviderOrderInSection(connectedSection)).toEqual(["anthropic"]);
const quickStartSection = screen.getByTestId("onboarding-quick-start-providers");
expect(getProviderOrderInSection(quickStartSection)).toEqual(["openai"]);
expect(
connectedSection.compareDocumentPosition(quickStartSection) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
// The "N of M providers connected" summary keeps working with the new layout.
expect(screen.getByTestId("provider-summary")).toHaveTextContent("1 of 2 providers connected");
});
it("renders onboarding provider icon wrapper for connected advanced providers", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
@@ -1705,7 +1754,10 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByText(/Dashboard GitHub OAuth is not configured/)).toBeTruthy();
expect(screen.getByText(/GitHub CLI setup guidance above/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Continue without GitHub →" })).toBeTruthy();
// FNXC:Onboarding 2026-07-10-10:35: the in-body "Continue without GitHub →" CTA was
// removed — footer "Skip GitHub →" is the single skip affordance for this step.
expect(screen.queryByRole("button", { name: "Continue without GitHub →" })).toBeNull();
expect(screen.getByRole("button", { name: "Skip GitHub →" })).toBeTruthy();
});
it("shows feature availability callout on GitHub step", async () => {
@@ -1812,7 +1864,8 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByTestId("onboarding-git-prerequisite")).toHaveTextContent("Git prerequisite ready");
expect(screen.queryByTestId("github-status-badge")).toBeNull();
expect(screen.getByRole("button", { name: "Continue without GitHub →" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Continue without GitHub →" })).toBeNull();
expect(screen.getByRole("button", { name: "Skip GitHub →" })).toBeTruthy();
});
it("does not treat GitHub Copilot provider auth as GitHub integration readiness", async () => {
@@ -1829,7 +1882,8 @@ describe("ModelOnboardingModal", () => {
expect(screen.queryByTestId("github-status-badge")).toBeNull();
expect(screen.getByText(/Dashboard GitHub OAuth is not configured/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Continue without GitHub →" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Continue without GitHub →" })).toBeNull();
expect(screen.getByRole("button", { name: "Skip GitHub →" })).toBeTruthy();
expect(screen.queryByText(/GitHub is connected — issue imports/)).toBeNull();
});
@@ -1869,9 +1923,16 @@ describe("ModelOnboardingModal", () => {
const installCard = screen.getByTestId("onboarding-gh-cli-install-card");
expect(installCard).toHaveTextContent("Install GitHub CLI");
expect(installCard).toHaveTextContent("host running Fusion");
// FNXC:Onboarding 2026-07-10-10:30: the per-OS install wall of text is collapsed
// behind a single disclosure; expand it to see the platform instructions, and the
// `gh` / `brew install gh` commands render as real <code> elements.
expect(installCard).not.toHaveTextContent("macOS");
fireEvent.click(within(installCard).getByRole("button", { name: /Show install instructions/ }));
expect(installCard).toHaveTextContent("macOS");
expect(installCard).toHaveTextContent("Windows");
expect(installCard).toHaveTextContent("Linux");
expect(installCard.querySelector("code")).toBeTruthy();
expect(installCard).not.toHaveTextContent("`gh`");
expect(screen.getByRole("link", { name: "Open GitHub CLI releases" })).toHaveAttribute("href", "https://github.com/cli/cli/releases/latest");
expect(screen.getByRole("button", { name: /Connect GitHub OAuth/ })).toBeTruthy();
expect(screen.getByRole("button", { name: "Skip GitHub →" })).toBeTruthy();
@@ -1890,7 +1951,8 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByTestId("onboarding-gh-cli-install-card")).toHaveTextContent("Install GitHub CLI");
expect(screen.getByText(/Dashboard GitHub OAuth is not configured/)).toBeTruthy();
expect(screen.queryByRole("button", { name: /Connect GitHub OAuth/ })).toBeNull();
expect(screen.getByRole("button", { name: "Continue without GitHub →" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Continue without GitHub →" })).toBeNull();
expect(screen.getByRole("button", { name: "Skip GitHub →" })).toBeTruthy();
});
it("preserves missing-Git guidance when GitHub OAuth provider is absent and gh CLI is ready", async () => {
@@ -1947,7 +2009,8 @@ describe("ModelOnboardingModal", () => {
await navigateToGitHubStep();
expect(screen.queryByTestId("onboarding-git-prerequisite")).toBeNull();
expect(screen.getByRole("button", { name: "Continue without GitHub →" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Continue without GitHub →" })).toBeNull();
expect(screen.getByRole("button", { name: "Skip GitHub →" })).toBeTruthy();
});
it("GitHub step shows connected state when already authenticated", async () => {
@@ -1975,6 +2038,86 @@ describe("ModelOnboardingModal", () => {
expect(screen.queryByTestId("onboarding-github-connect-cta")).toBeNull();
});
it("state-driven GitHub step: a ready connection suppresses install/auth guidance and the feature pitch", async () => {
/*
FNXC:Onboarding 2026-07-10-10:30:
Surface Enumeration for the state-driven step: when GitHub is ready via EITHER path (OAuth or
gh CLI), neither gh CLI setup card nor the with/without feature list may render — one primary
path per state. Previously an OAuth-connected user was still told to install the GitHub CLI.
*/
const readyScenarios = [
{
label: "OAuth connected, gh missing",
providers: [{ id: "github", name: "GitHub", authenticated: true, type: "oauth" }],
ghCli: { available: false, authenticated: false },
},
{
label: "OAuth connected, gh unauthenticated",
providers: [{ id: "github", name: "GitHub", authenticated: true, type: "oauth" }],
ghCli: { available: true, authenticated: false },
},
{
label: "gh authenticated, no OAuth provider",
providers: [],
ghCli: { available: true, authenticated: true },
},
];
for (const scenario of readyScenarios) {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: scenario.providers,
ghCli: scenario.ghCli,
});
const { unmount } = render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await navigateToGitHubStep();
expect(screen.queryByTestId("onboarding-gh-cli-install-card")).toBeNull();
expect(screen.queryByTestId("onboarding-gh-cli-auth-card")).toBeNull();
expect(document.querySelector(".onboarding-feature-list")).toBeNull();
expect(screen.queryByRole("button", { name: "Continue without GitHub →" })).toBeNull();
unmount();
}
});
it("revalidates GitHub connection status when the window regains focus (mid-session gh auth)", async () => {
/*
FNXC:Onboarding 2026-07-10-10:10:
Regression test for the stale-status bug: the user authenticated the gh CLI in a terminal while
the wizard stayed open, and later steps still showed GitHub as not connected. Regaining window
focus must refetch auth/gh status and flip the step to connected.
*/
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
ghCli: { available: true, authenticated: false },
}));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await navigateToGitHubStep();
expect(screen.getByTestId("github-status-badge")).toHaveTextContent("Not connected");
// The user runs `gh auth login` in a terminal, then switches back to the dashboard.
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
ghCli: { available: true, authenticated: true },
}));
fireEvent(window, new Event("focus"));
await waitFor(() => {
expect(screen.getByTestId("github-status-badge")).toHaveTextContent("✓ Connected");
});
expect(screen.queryByTestId("onboarding-gh-cli-auth-card")).toBeNull();
});
it("GitHub step explains gh CLI auth when OAuth provider is absent", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [],
@@ -2211,12 +2354,14 @@ describe("ModelOnboardingModal", () => {
});
});
it("allows navigating to First Task step via Continue without GitHub", async () => {
it("allows navigating to First Task step via footer Skip GitHub", async () => {
// FNXC:Onboarding 2026-07-10-10:35: the in-body "Continue without GitHub →" CTA was
// removed; footer "Skip GitHub →" is the single skip affordance for this step.
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await navigateToGitHubStep();
fireEvent.click(screen.getByText("Continue without GitHub →"));
fireEvent.click(screen.getByText("Skip GitHub →"));
await waitFor(() => {
expect(screen.getByText("Set Up Your Project")).toBeTruthy();

View File

@@ -147,21 +147,21 @@ describe("SetupProjectForm", () => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// Check options directly by querying the select's children
// FNXC:SetupWizard 2026-07-10-11:00: registered local-type node records must NOT
// render as a second near-identical option next to the built-in "Local node" default.
const options = Array.from(select.querySelectorAll("option"));
const optionValues = options.map((opt) => opt.value);
expect(optionValues).toContain("local-1");
expect(optionValues).not.toContain("local-1");
expect(optionValues).toContain("remote-1");
expect(optionValues.filter((value) => value === "")).toHaveLength(1);
});
it("does not render extra node options when only local node is in list", () => {
it("hides the node selector when only a local node is in the list", () => {
render(<SetupProjectForm onSubmit={vi.fn()} nodes={[localNode]} />);
// When only the local node is provided, it appears as the default option
// The extra options from nodes.map should include localNode's entry
const select = screen.getByRole("combobox") as HTMLSelectElement;
const options = Array.from(select.querySelectorAll("option"));
// Should have at least the local node option
expect(options.some((opt) => opt.value === "local-1")).toBe(true);
// FNXC:SetupWizard 2026-07-10-11:00: a single-local-node install has no runtime
// choice to make — the selector (and its duplicate local option) is gone entirely.
expect(screen.queryByText("Runtime Node")).toBeNull();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("uses selectedNodeId as default value", () => {

View File

@@ -170,6 +170,23 @@ describe("SetupWizardModal", () => {
);
});
it("keeps onboarding step context when opened as the onboarding Project sub-flow", () => {
// FNXC:SetupWizard 2026-07-10-11:05: opened from the 5-step onboarding wizard
// (includeAgentStep=false), the header must keep the "Step 3 of 5" context instead
// of the standalone "Welcome to Fusion" chrome, so users don't feel ejected from setup.
render(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
includeAgentStep={false}
/>
);
expect(screen.getByText("Step 3 of 5 — Project · Fusion setup")).toBeDefined();
expect(screen.getByText("Set Up Your Project")).toBeDefined();
expect(screen.queryByText("Welcome to Fusion")).toBeNull();
});
it("has DirectoryPicker for path selection", () => {
render(
<SetupWizardModal
@@ -872,7 +889,9 @@ describe("SetupWizardModal", () => {
fireEvent.click(screen.getByText("Advanced settings"));
expect(screen.getByText("Runtime Node")).toBeDefined();
// FNXC:SetupWizard 2026-07-10-11:00: with no non-local node registered there is
// nothing to choose, so the Runtime Node selector stays hidden in advanced settings.
expect(screen.queryByText("Runtime Node")).toBeNull();
expect(screen.getByText("In-Process")).toBeDefined();
expect(screen.getByText("Child-Process")).toBeDefined();
expect(screen.getByText("Recommended")).toBeDefined();
@@ -945,12 +964,16 @@ describe("SetupWizardModal", () => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// Check options directly by querying the select's children
// FNXC:SetupWizard 2026-07-10-11:00: registered local-type node records must NOT
// render as a second near-identical "local (local)" option — the built-in
// "Local node" default option already represents the local machine.
const options = Array.from(select.querySelectorAll("option"));
const optionValues = options.map((opt) => opt.value);
const optionLabels = options.map((opt) => opt.label);
expect(optionValues).toContain("local-1");
expect(optionValues).not.toContain("local-1");
expect(optionValues).toContain("remote-1");
expect(optionValues.filter((value) => value === "")).toHaveLength(1);
// Plain-language explanation of what a runtime node is.
expect(screen.getByText(/A runtime node is the machine where this project's tasks run/)).toBeDefined();
});
it("registration includes selected nodeId", async () => {
@@ -1004,7 +1027,7 @@ describe("SetupWizardModal", () => {
});
});
it("registration includes undefined nodeId when local node selected", async () => {
it("registration includes undefined nodeId when only a local node exists (selector hidden)", async () => {
mockUseNodes.mockImplementation(() => ({
nodes: [localNode],
loading: false,
@@ -1028,9 +1051,10 @@ describe("SetupWizardModal", () => {
fireEvent.click(screen.getByText("Advanced settings"));
// Local node is selected by default (empty value)
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
// FNXC:SetupWizard 2026-07-10-11:00: a single-local-node install has no runtime
// choice to make, so the selector is hidden and registration runs locally.
expect(screen.queryByText("Runtime Node")).toBeNull();
expect(screen.queryByRole("combobox")).toBeNull();
// Fill path and name
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {

View File

@@ -448,11 +448,19 @@ describe("onboarding flow integration", () => {
expect(screen.getByText("You only need one provider to get started.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Advanced provider settings/ })).toHaveAttribute("aria-expanded", "false");
/*
FNXC:Onboarding 2026-07-10-10:15:
ALL connected providers (quick-start and advanced alike) render in the "Connected providers"
section at the TOP of the step, above quick start; quick start only offers unconnected providers.
*/
const quickStartSection = screen.getByTestId("onboarding-quick-start-providers");
expect(getProviderOrderInSection(quickStartSection)).toEqual(["anthropic", "openai", "google", "openrouter"]);
expect(getProviderOrderInSection(quickStartSection)).toEqual(["openai", "google", "openrouter"]);
const connectedSection = screen.getByTestId("onboarding-connected-providers");
expect(getProviderOrderInSection(connectedSection)).toEqual(["zai"]);
expect(getProviderOrderInSection(connectedSection)).toEqual(["anthropic", "zai"]);
expect(
connectedSection.compareDocumentPosition(quickStartSection) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.queryByTestId("onboarding-provider-card-minimax")).not.toBeInTheDocument();
expect(screen.queryByTestId("onboarding-provider-card-moonshot")).not.toBeInTheDocument();

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import type { NodeInfo } from "../../api";
import { getSelectableRuntimeNodes, shouldShowRuntimeNodeSelector } from "../setupWizardNodes";
/*
FNXC:SetupWizard 2026-07-10-11:00:
First-run review: the wizard's Runtime Node dropdown showed both the built-in "Local node" default and
a registered local node record as "local (local)". These tests pin the dedupe rule: local-type node
records never appear as options, and the selector only exists when a non-local node is registered.
*/
function buildNode(overrides: Partial<NodeInfo> & Pick<NodeInfo, "id" | "type">): NodeInfo {
return {
name: overrides.id,
status: "online",
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as NodeInfo;
}
describe("setupWizardNodes", () => {
it("filters registered local-type nodes out of the selectable list", () => {
const nodes = [
buildNode({ id: "local-1", type: "local" }),
buildNode({ id: "remote-1", type: "remote" }),
];
expect(getSelectableRuntimeNodes(nodes).map((node) => node.id)).toEqual(["remote-1"]);
});
it("hides the selector when only local nodes exist (single-local-node install)", () => {
expect(shouldShowRuntimeNodeSelector([buildNode({ id: "local-1", type: "local" })])).toBe(false);
expect(shouldShowRuntimeNodeSelector([])).toBe(false);
});
it("shows the selector when at least one non-local node exists", () => {
const nodes = [
buildNode({ id: "local-1", type: "local" }),
buildNode({ id: "remote-1", type: "remote" }),
];
expect(shouldShowRuntimeNodeSelector(nodes)).toBe(true);
});
});

View File

@@ -0,0 +1,24 @@
import type { NodeInfo } from "../api";
/*
FNXC:SetupWizard 2026-07-10-11:00:
First-run review (project registration, Advanced settings): the Runtime Node dropdown showed two
near-identical options — the built-in "Local node" default option (empty value = run on this machine)
PLUS a registered local node record rendered as "local (local)". A single-local-node install must show
exactly one local choice, so:
- registered nodes of type "local" are filtered out of the option list (the built-in default option
already represents them; registering with an empty nodeId runs locally), and
- the selector is hidden entirely when there is no non-local node to pick — a choice with one option
is noise during first-run setup.
Kept as a pure module so the dedupe rule is unit-testable without rendering the wizard.
*/
/** Nodes that are meaningful to offer in the Runtime Node dropdown (everything except local-type records). */
export function getSelectableRuntimeNodes(nodes: NodeInfo[]): NodeInfo[] {
return nodes.filter((node) => node.type !== "local");
}
/** The Runtime Node selector is only shown when there is at least one non-local node to choose. */
export function shouldShowRuntimeNodeSelector(nodes: NodeInfo[]): boolean {
return getSelectableRuntimeNodes(nodes).length > 0;
}

View File

@@ -6849,11 +6849,12 @@
"githubCliAuthTitle": "Authenticate GitHub CLI",
"githubCliInstallBody": "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.",
"githubCliInstallLink": "Open GitHub CLI releases",
"githubCliInstallShowInstructions": "Show install instructions",
"githubCliInstallLinux": "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.",
"githubCliInstallMac": "macOS: `brew install gh` or download the installer from GitHub CLI releases.",
"githubCliInstallTitle": "Install GitHub CLI",
"githubCliInstallWindows": "Windows: install GitHub CLI with WinGet, Chocolatey, or the GitHub CLI installer, then restart the Fusion host shell or service.",
"githubOauthUnavailable": "Dashboard GitHub OAuth is not configured on this Fusion host. You can still continue without GitHub, or use the GitHub CLI setup guidance above when available.",
"githubOauthUnavailable": "Dashboard GitHub OAuth is not configured on this Fusion host. You can still skip GitHub below, or use the GitHub CLI setup guidance above when available.",
"githubCliAuthNote": "GitHub CLI is already authenticated, so imports and PR tracking work now. OAuth from the dashboard is optional and only controls dashboard-managed connect/disconnect.",
"githubCliAuthSuccess": "GitHub CLI is authenticated. Imports and pull request tracking are available. Connect OAuth in Settings → Authentication if you want dashboard-managed sign-in controls.",
"githubConnected": "GitHub is connected — issue imports and pull request tracking are available. You're all set; no further action needed.",
@@ -6927,12 +6928,14 @@
"projectRegisteredSuccess": "Your project has been registered successfully.",
"projectRequired": "A project is required before first-task actions are available.",
"projectSelected": "Project selected — task creation and imports are available.",
"projectStepContext": "Step 3 of 5 — Project · Fusion setup",
"projectSetupDescription": "Choose your first project before creating or importing tasks. You can register an existing local directory or clone a GitHub repository URL through the setup wizard.",
"providersConnectedSummary": "✓ {{connected}} of {{total}} provider(s) connected",
"providersSkippedSummary_one_one": "{{count}} provider skipped",
"providersSkippedSummary_one_other": "{{count}} provider skipped",
"providersSkippedSummary_other_one": "{{count}} providers skipped",
"providersSkippedSummary_other_other": "{{count}} providers skipped",
"quickStartAllConnected": "All quick-start providers are already connected.",
"quickStartProviders": "Quick start providers",
"readinessAiProviderConnected": "{{name}} connected — AI agents can work on tasks",
"readinessAiProviderLabel": "AI Provider",
@@ -6964,6 +6967,7 @@
"retry": "Retry",
"reviewStep": "Review {{label}}",
"runtimeNode": "Runtime Node",
"runtimeNodeHint": "A runtime node is the machine where this project's tasks run. \"Local node\" is this computer; pick a remote node to run tasks elsewhere.",
"savedProfileButFailedToActivate": "Saved profile but failed to activate it",
"saveKey": "Save",
"saveRemoteServer": "Save remote server",
@@ -7030,11 +7034,11 @@
"withGitHub1": "Import issues as tasks",
"withGitHub2": "Sync pull request status",
"withGitHub3": "Link code changes to tasks",
"withGitHubHeading": "With GitHub (after connecting):",
"withGitHubHeading": "With GitHub (after connecting)",
"withoutGitHub1": "Create tasks manually",
"withoutGitHub2": "Describe work for AI agents",
"withoutGitHub3": "Track progress on the board",
"withoutGitHubHeading": "Without GitHub (available now):",
"withoutGitHubHeading": "Without GitHub (available now)",
"brandLogo": "Fusion logo",
"brandName": "Fusion",
"setupCompleteTitle": "Setup Complete!",

View File

@@ -1386,7 +1386,8 @@
"untitledSession": "Sin título",
"workingStatus": "Trabajando…",
"you": "Tú",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens"
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
},
"chatRooms": {
"error": {
@@ -6837,6 +6838,7 @@
"githubCliAuthBody": "GitHub CLI is installed but not authenticated on the Fusion host. Run this command in the environment where Fusion is running, then return here and continue.",
"githubCliAuthTitle": "Authenticate GitHub CLI",
"githubCliInstallBody": "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.",
"githubCliInstallShowInstructions": "Mostrar instrucciones de instalación",
"githubCliInstallLink": "Open GitHub CLI releases",
"githubCliInstallLinux": "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.",
"githubCliInstallMac": "macOS: `brew install gh` or download the installer from GitHub CLI releases.",
@@ -6916,12 +6918,14 @@
"projectRegisteredSuccess": "Su proyecto se ha registrado exitosamente.",
"projectRequired": "Se requiere un proyecto antes de que las acciones de primera tarea estén disponibles.",
"projectSelected": "Proyecto seleccionado — la creación de tareas y las importaciones están disponibles.",
"projectStepContext": "Paso 3 de 5 — Proyecto · Configuración de Fusion",
"projectSetupDescription": "Elige tu primer proyecto antes de crear o importar tareas. Puedes registrar un directorio local existente o clonar una URL de repositorio de GitHub a través del asistente de configuración.",
"providersConnectedSummary": "✓ {{connected}} de {{total}} proveedor(es) conectado(s)",
"providersSkippedSummary_one_one": "",
"providersSkippedSummary_one_other": "",
"providersSkippedSummary_other_one": "",
"providersSkippedSummary_other_other": "",
"quickStartAllConnected": "Todos los proveedores de inicio rápido ya están conectados.",
"quickStartProviders": "Proveedores de inicio rápido",
"readinessAiProviderConnected": "{{name}} conectado — los agentes de IA pueden trabajar en las tareas",
"readinessAiProviderLabel": "Proveedor de IA",
@@ -6953,6 +6957,7 @@
"retry": "Reintentar",
"reviewStep": "Revisar {{label}}",
"runtimeNode": "Nodo en tiempo de ejecución",
"runtimeNodeHint": "Un nodo de ejecución es la máquina donde se ejecutan las tareas de este proyecto. \"Nodo local\" es este equipo; elige un nodo remoto para ejecutar tareas en otro lugar.",
"savedProfileButFailedToActivate": "Se guardó el perfil pero no se pudo activar",
"saveKey": "Guardar",
"saveRemoteServer": "Guardar servidor remoto",

View File

@@ -1386,7 +1386,8 @@
"untitledSession": "Sans titre",
"workingStatus": "Traitement en cours…",
"you": "Vous",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens"
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
},
"chatRooms": {
"error": {
@@ -6837,6 +6838,7 @@
"githubCliAuthBody": "GitHub CLI is installed but not authenticated on the Fusion host. Run this command in the environment where Fusion is running, then return here and continue.",
"githubCliAuthTitle": "Authenticate GitHub CLI",
"githubCliInstallBody": "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.",
"githubCliInstallShowInstructions": "Afficher les instructions d'installation",
"githubCliInstallLink": "Open GitHub CLI releases",
"githubCliInstallLinux": "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.",
"githubCliInstallMac": "macOS: `brew install gh` or download the installer from GitHub CLI releases.",
@@ -6916,12 +6918,14 @@
"projectRegisteredSuccess": "Votre projet a été enregistré avec succès.",
"projectRequired": "Un projet est requis avant que les actions de première tâche soient disponibles.",
"projectSelected": "Projet sélectionné — la création de tâches et les imports sont disponibles.",
"projectStepContext": "Étape 3 sur 5 — Projet · Configuration de Fusion",
"projectSetupDescription": "Choisissez votre premier projet avant de créer ou d'importer des tâches. Vous pouvez enregistrer un répertoire local existant ou cloner une URL de dépôt GitHub via l'assistant de configuration.",
"providersConnectedSummary": "✓ {{connected}} fournisseur(s) sur {{total}} connecté(s)",
"providersSkippedSummary_one_one": "",
"providersSkippedSummary_one_other": "",
"providersSkippedSummary_other_one": "",
"providersSkippedSummary_other_other": "",
"quickStartAllConnected": "Tous les fournisseurs de démarrage rapide sont déjà connectés.",
"quickStartProviders": "Fournisseurs de démarrage rapide",
"readinessAiProviderConnected": "{{name}} connecté — les agents IA peuvent travailler sur les tâches",
"readinessAiProviderLabel": "Fournisseur IA",
@@ -6953,6 +6957,7 @@
"retry": "Réessayer",
"reviewStep": "Revoir {{label}}",
"runtimeNode": "Nœud d'exécution",
"runtimeNodeHint": "Un nœud d'exécution est la machine où s'exécutent les tâches de ce projet. « Nœud local » désigne cet ordinateur ; choisissez un nœud distant pour exécuter les tâches ailleurs.",
"savedProfileButFailedToActivate": "Profil enregistré mais échec de l'activation",
"saveKey": "Enregistrer",
"saveRemoteServer": "Enregistrer le serveur distant",

View File

@@ -1386,7 +1386,8 @@
"untitledSession": "제목 없음",
"workingStatus": "작업 중…",
"you": "나",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens"
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
},
"chatRooms": {
"error": {
@@ -6837,6 +6838,7 @@
"githubCliAuthBody": "GitHub CLI is installed but not authenticated on the Fusion host. Run this command in the environment where Fusion is running, then return here and continue.",
"githubCliAuthTitle": "Authenticate GitHub CLI",
"githubCliInstallBody": "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.",
"githubCliInstallShowInstructions": "설치 안내 보기",
"githubCliInstallLink": "Open GitHub CLI releases",
"githubCliInstallLinux": "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.",
"githubCliInstallMac": "macOS: `brew install gh` or download the installer from GitHub CLI releases.",
@@ -6916,12 +6918,14 @@
"projectRegisteredSuccess": "프로젝트가 성공적으로 등록되었습니다.",
"projectRequired": "첫 번째 작업 동작을 사용하려면 프로젝트가 필요합니다.",
"projectSelected": "프로젝트 선택됨 — 작업 생성 및 가져오기를 사용할 수 있습니다.",
"projectStepContext": "5단계 중 3단계 — 프로젝트 · Fusion 설정",
"projectSetupDescription": "작업을 생성하거나 가져오기 전에 첫 번째 프로젝트를 선택하세요. 기존 로컬 디렉터리를 등록하거나, 설정 마법사를 통해 GitHub 저장소 URL을 클론할 수 있습니다.",
"providersConnectedSummary": "✓ {{total}}개 제공자 중 {{connected}}개 연결됨",
"providersSkippedSummary_one_one": "",
"providersSkippedSummary_one_other": "",
"providersSkippedSummary_other_one": "",
"providersSkippedSummary_other_other": "",
"quickStartAllConnected": "빠른 시작 제공자가 모두 이미 연결되어 있습니다.",
"quickStartProviders": "빠른 시작 제공자",
"readinessAiProviderConnected": "{{name}} 연결됨 — AI 에이전트가 작업을 처리할 수 있습니다",
"readinessAiProviderLabel": "AI 제공자",
@@ -6953,6 +6957,7 @@
"retry": "재시도",
"reviewStep": "{{label}} 검토",
"runtimeNode": "런타임 노드",
"runtimeNodeHint": "런타임 노드는 이 프로젝트의 작업이 실행되는 머신입니다. \"로컬 노드\"는 이 컴퓨터이며, 다른 곳에서 작업을 실행하려면 원격 노드를 선택하세요.",
"savedProfileButFailedToActivate": "프로필을 저장했지만 활성화에 실패했습니다",
"saveKey": "저장",
"saveRemoteServer": "원격 서버 저장",

View File

@@ -1386,7 +1386,8 @@
"untitledSession": "无标题",
"workingStatus": "处理中……",
"you": "你",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens"
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
},
"chatRooms": {
"error": {
@@ -6837,6 +6838,7 @@
"githubCliAuthBody": "GitHub CLI is installed but not authenticated on the Fusion host. Run this command in the environment where Fusion is running, then return here and continue.",
"githubCliAuthTitle": "Authenticate GitHub CLI",
"githubCliInstallBody": "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.",
"githubCliInstallShowInstructions": "显示安装说明",
"githubCliInstallLink": "Open GitHub CLI releases",
"githubCliInstallLinux": "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.",
"githubCliInstallMac": "macOS: `brew install gh` or download the installer from GitHub CLI releases.",
@@ -6916,12 +6918,14 @@
"projectRegisteredSuccess": "您的项目已成功注册。",
"projectRequired": "在可以使用第一个任务操作之前,需要先有一个项目。",
"projectSelected": "已选择项目——任务创建和导入功能已可用。",
"projectStepContext": "第 3 步(共 5 步)— 项目 · Fusion 设置",
"projectSetupDescription": "在创建或导入任务之前,请选择您的第一个项目。您可以注册现有的本地目录,或通过设置向导克隆 GitHub 仓库 URL。",
"providersConnectedSummary": "✓ {{total}} 个提供商中已连接 {{connected}} 个",
"providersSkippedSummary_one_one": "",
"providersSkippedSummary_one_other": "",
"providersSkippedSummary_other_one": "",
"providersSkippedSummary_other_other": "",
"quickStartAllConnected": "所有快速开始提供商均已连接。",
"quickStartProviders": "快速启动提供商",
"readinessAiProviderConnected": "{{name}} 已连接——AI 代理可以处理任务",
"readinessAiProviderLabel": "AI 提供商",
@@ -6953,6 +6957,7 @@
"retry": "重试",
"reviewStep": "查看{{label}}",
"runtimeNode": "运行时节点",
"runtimeNodeHint": "运行时节点是运行此项目任务的机器。\"本地节点\"即这台电脑;选择远程节点可在其他地方运行任务。",
"savedProfileButFailedToActivate": "配置文件已保存但激活失败",
"saveKey": "保存",
"saveRemoteServer": "保存远程服务器",

View File

@@ -1386,7 +1386,8 @@
"untitledSession": "未命名",
"workingStatus": "處理中……",
"you": "你",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens"
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
},
"chatRooms": {
"error": {
@@ -6837,6 +6838,7 @@
"githubCliAuthBody": "GitHub CLI is installed but not authenticated on the Fusion host. Run this command in the environment where Fusion is running, then return here and continue.",
"githubCliAuthTitle": "Authenticate GitHub CLI",
"githubCliInstallBody": "Fusion could not find `gh` on the host running Fusion. Install GitHub CLI there to import issues and track pull requests with CLI authentication.",
"githubCliInstallShowInstructions": "顯示安裝說明",
"githubCliInstallLink": "Open GitHub CLI releases",
"githubCliInstallLinux": "Linux: install `gh` with your distribution package manager or the packages from cli.github.com.",
"githubCliInstallMac": "macOS: `brew install gh` or download the installer from GitHub CLI releases.",
@@ -6916,12 +6918,14 @@
"projectRegisteredSuccess": "您的專案已成功註冊。",
"projectRequired": "在可以使用第一個任務操作之前,需要先有一個專案。",
"projectSelected": "已選擇專案——任務建立和匯入功能已可用。",
"projectStepContext": "第 3 步(共 5 步)— 專案 · Fusion 設定",
"projectSetupDescription": "在建立或匯入任務之前,請選擇您的第一個專案。您可以註冊現有的本機目錄,或透過設定精靈複製 GitHub 儲存庫 URL。",
"providersConnectedSummary": "✓ {{total}} 個提供商中已連接 {{connected}} 個",
"providersSkippedSummary_one_one": "",
"providersSkippedSummary_one_other": "",
"providersSkippedSummary_other_one": "",
"providersSkippedSummary_other_other": "",
"quickStartAllConnected": "所有快速開始供應商均已連線。",
"quickStartProviders": "快速啟動提供商",
"readinessAiProviderConnected": "{{name}} 已連接——AI 代理可以處理任務",
"readinessAiProviderLabel": "AI 提供商",
@@ -6953,6 +6957,7 @@
"retry": "重試",
"reviewStep": "查看{{label}}",
"runtimeNode": "執行時節點",
"runtimeNodeHint": "執行節點是執行此專案任務的機器。「本機節點」即這台電腦;選擇遠端節點可在其他地方執行任務。",
"savedProfileButFailedToActivate": "設定檔已儲存但啟用失敗",
"saveKey": "儲存",
"saveRemoteServer": "儲存遠端伺服器",