feat(FN-1934): add GitHub onboarding status feedback states

- Derive GitHub-specific onboarding status from auth state, login outcomes, and persisted skip metadata
- Replace static GitHub auth text with status badge variants and contextual success/error/info feedback blocks
- Add retry, skip, and connect-anyway actions, including persistence and reset of skipped state on successful auth
- Style new GitHub feedback UI and badge states using dashboard tokens with mobile touch-target adjustments
- Expand ModelOnboardingModal tests to cover connected, pending, failed, skipped, and recovery flows
This commit is contained in:
Fusion
2026-04-17 01:09:16 -07:00
committed by gsxdsm
parent 1a10a03c26
commit d793b9cba1
3 changed files with 374 additions and 30 deletions

View File

@@ -284,6 +284,9 @@ export type LoginOutcome = "pending" | "success" | "timeout" | "failed" | "cance
/** Provider connection status for UI display */ /** Provider connection status for UI display */
export type ProviderConnectionStatus = "connected" | "not-connected" | "skipped" | "retry"; export type ProviderConnectionStatus = "connected" | "not-connected" | "skipped" | "retry";
/** GitHub-specific status variants for richer connection feedback */
type GitHubConnectionStatus = "connected" | "failed" | "pending" | "skipped" | "not-connected";
/** Maximum number of poll cycles before timing out (150 × 2s = 5 minutes) */ /** Maximum number of poll cycles before timing out (150 × 2s = 5 minutes) */
const MAX_POLL_CYCLES = 150; const MAX_POLL_CYCLES = 150;
@@ -322,6 +325,10 @@ export function ModelOnboardingModal({
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({}); const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({}); const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
const state = getOnboardingState();
return state?.stepData?.github?.skipped === true;
});
const pollCountRef = useRef<number>(0); const pollCountRef = useRef<number>(0);
// Initialize skippedProviders from persisted state // Initialize skippedProviders from persisted state
@@ -408,6 +415,11 @@ export function ModelOnboardingModal({
aiSetupReturnRef.current = step !== "ai-setup"; aiSetupReturnRef.current = step !== "ai-setup";
}, [step, loadAuthStatus]); }, [step, loadAuthStatus]);
// Check if GitHub provider is configured and currently authenticated
const githubProvider = authProviders.find((p) => p.id === "github");
const hasGithubProvider = !!githubProvider;
const isGithubAuthenticated = githubProvider?.authenticated ?? false;
// Get provider connection status for UI display // Get provider connection status for UI display
const getProviderStatus = useCallback((provider: AuthProvider): ProviderConnectionStatus => { const getProviderStatus = useCallback((provider: AuthProvider): ProviderConnectionStatus => {
if (provider.authenticated) { if (provider.authenticated) {
@@ -444,6 +456,46 @@ export function ModelOnboardingModal({
); );
} }
const getGitHubStatus = useCallback((): GitHubConnectionStatus => {
if (isGithubAuthenticated) {
return "connected";
}
const githubOutcome = loginOutcomes["github"];
if (githubOutcome === "pending") {
return "pending";
}
if (githubOutcome === "failed" || githubOutcome === "timeout") {
return "failed";
}
if (isGithubSkipped) {
return "skipped";
}
return "not-connected";
}, [isGithubAuthenticated, loginOutcomes, isGithubSkipped]);
function GitHubStatusBadge({ status }: { status: GitHubConnectionStatus }) {
const config: Record<GitHubConnectionStatus, { text: string; className: string }> = {
connected: { text: "✓ Connected", className: "auth-status-badge connected" },
pending: { text: "⏳ Connecting…", className: "auth-status-badge pending" },
failed: { text: "✗ Connection failed", className: "auth-status-badge retry" },
skipped: { text: "Skipped", className: "auth-status-badge skipped" },
"not-connected": { text: "Not connected", className: "auth-status-badge not-connected" },
};
const { text, className: badgeClassName } = config[status];
return (
<span
data-testid="github-status-badge"
className={badgeClassName}
data-status={status}
>
{text}
</span>
);
}
// Load models // Load models
const loadModels = useCallback(async () => { const loadModels = useCallback(async () => {
try { try {
@@ -517,11 +569,6 @@ export function ModelOnboardingModal({
} }
}, [loginOutcomes, persistLoginOutcome]); }, [loginOutcomes, persistLoginOutcome]);
// Check if we have GitHub provider
const githubProvider = authProviders.find((p) => p.id === "github");
const hasGithubProvider = !!githubProvider;
const isGithubAuthenticated = githubProvider?.authenticated ?? false;
// Cleanup polling on unmount // Cleanup polling on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -554,6 +601,25 @@ export function ModelOnboardingModal({
} }
}, [step]); }, [step]);
const setGitHubSkippedState = useCallback((skipped: boolean) => {
setIsGithubSkipped(skipped);
saveOnboardingState(step, {
completedSteps,
stepData: {
github: {
skipped,
},
},
});
}, [step, completedSteps]);
const handleSkipGitHubStep = useCallback(() => {
if (!isGithubAuthenticated) {
setGitHubSkippedState(true);
}
handleNext();
}, [isGithubAuthenticated, setGitHubSkippedState, handleNext]);
// OAuth login handler // OAuth login handler
const handleLogin = useCallback( const handleLogin = useCallback(
async (providerId: string) => { async (providerId: string) => {
@@ -603,6 +669,9 @@ export function ModelOnboardingModal({
} }
setAuthActionInProgress(null); setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
if (providerId === "github") {
setGitHubSkippedState(false);
}
addToast("Login successful", "success"); addToast("Login successful", "success");
} }
} catch { } catch {
@@ -625,7 +694,7 @@ export function ModelOnboardingModal({
setAuthActionInProgress(null); setAuthActionInProgress(null);
} }
}, },
[addToast], [addToast, setGitHubSkippedState],
); );
// Cancellation handler for in-progress logins // Cancellation handler for in-progress logins
@@ -893,6 +962,8 @@ export function ModelOnboardingModal({
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github"); const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github"); const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github");
const githubStatus = getGitHubStatus();
// Skip-state detection: derived state for informational banners // Skip-state detection: derived state for informational banners
// Detects whether at least one AI provider is connected (excludes GitHub) // Detects whether at least one AI provider is connected (excludes GitHub)
const hasAiProvider = authProviders.some((p) => p.id !== "github" && p.authenticated); const hasAiProvider = authProviders.some((p) => p.id !== "github" && p.authenticated);
@@ -1307,13 +1378,8 @@ export function ModelOnboardingModal({
<GitPullRequest size={16} style={{ marginRight: 8 }} /> <GitPullRequest size={16} style={{ marginRight: 8 }} />
GitHub GitHub
</strong> </strong>
<span <span data-testid="onboarding-auth-status-github">
data-testid="onboarding-auth-status-github" <GitHubStatusBadge status={githubStatus} />
className={`auth-status-badge ${isGithubAuthenticated ? "connected" : "not-connected"}`}
>
{isGithubAuthenticated
? "✓ Connected"
: "Not connected"}
</span> </span>
</div> </div>
{isGithubAuthenticated && ( {isGithubAuthenticated && (
@@ -1332,7 +1398,7 @@ export function ModelOnboardingModal({
)} )}
</div> </div>
{!isGithubAuthenticated && ( {(githubStatus === "not-connected" || githubStatus === "pending") && (
<div className="onboarding-github-connect-cta" data-testid="onboarding-github-connect-cta"> <div className="onboarding-github-connect-cta" data-testid="onboarding-github-connect-cta">
{authActionInProgress === "github" ? ( {authActionInProgress === "github" ? (
<div className="onboarding-github-connect-actions"> <div className="onboarding-github-connect-actions">
@@ -1355,23 +1421,56 @@ export function ModelOnboardingModal({
Connect Connect
</button> </button>
)} )}
{/* Show timeout message */}
{loginOutcomes["github"] === "timeout" && authActionInProgress !== "github" && (
<p className="onboarding-helper-text onboarding-github-connect-feedback">
Login timed out. Please try again.
</p>
)}
{/* Show failure message */}
{loginOutcomes["github"] === "failed" && authActionInProgress !== "github" && (
<p className="field-error onboarding-github-connect-feedback">
Login failed. Please try again.
</p>
)}
</div> </div>
)} )}
{!isGithubAuthenticated && ( {githubStatus === "connected" && (
<div className="onboarding-github-feedback onboarding-github-feedback--success">
GitHub is connected. You can import issues and track pull requests.
</div>
)}
{githubStatus === "failed" && (
<div className="onboarding-github-feedback onboarding-github-feedback--error">
<p>Connection failed or timed out.</p>
<div className="onboarding-github-feedback-actions">
<button
className="btn btn-sm"
onClick={() => handleLogin("github")}
>
Retry
</button>
<button
className="onboarding-skip-step-link"
onClick={handleSkipGitHubStep}
>
Skip for now
</button>
</div>
</div>
)}
{githubStatus === "pending" && (
<div className="onboarding-github-feedback onboarding-github-feedback--info">
Waiting for GitHub authorization…
</div>
)}
{githubStatus === "skipped" && (
<div className="onboarding-github-feedback onboarding-github-feedback--info">
<p>GitHub was skipped. You can connect anytime from Settings → Authentication.</p>
<div className="onboarding-github-feedback-actions">
<button
className="btn btn-sm"
onClick={() => handleLogin("github")}
>
Connect anyway
</button>
</div>
</div>
)}
{githubStatus === "not-connected" && (
<p className="onboarding-helper-text"> <p className="onboarding-helper-text">
No worries if you're not ready — connect GitHub anytime from Settings → Authentication. No worries if you're not ready — connect GitHub anytime from Settings → Authentication.
</p> </p>
@@ -1490,7 +1589,7 @@ export function ModelOnboardingModal({
<button className="btn btn-sm" onClick={handleBack}> <button className="btn btn-sm" onClick={handleBack}>
← Back ← Back
</button> </button>
<button className="onboarding-skip-step-link" onClick={handleNext}> <button className="onboarding-skip-step-link" onClick={handleSkipGitHubStep}>
Skip GitHub → Skip GitHub →
</button> </button>
<button className="btn btn-primary" onClick={handleNext}> <button className="btn btn-primary" onClick={handleNext}>

View File

@@ -540,6 +540,200 @@ describe("ModelOnboardingModal", () => {
expect(screen.queryByTestId("onboarding-github-connect-cta")).toBeNull(); expect(screen.queryByTestId("onboarding-github-connect-cta")).toBeNull();
}); });
describe("GitHub connection status feedback", () => {
it("shows connected status with success feedback", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("✓ Connected");
expect(badge).toHaveClass("connected");
expect(screen.getByText("GitHub is connected. You can import issues and track pull requests.")).toBeTruthy();
});
it("shows not-connected status and keeps default helper text", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("Not connected");
expect(badge).toHaveClass("not-connected");
expect(screen.queryByText("Connection failed or timed out.")).toBeNull();
expect(screen.getByText("No worries if you're not ready — connect GitHub anytime from Settings → Authentication.")).toBeTruthy();
});
it("shows pending status while GitHub login is in progress", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
mockLoginProvider.mockImplementationOnce(() => new Promise(() => {}));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByRole("button", { name: /Connect/ }));
await waitFor(() => {
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("⏳ Connecting…");
expect(badge).toHaveClass("pending");
});
});
it("shows failed status feedback with retry action", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
mockLoginProvider.mockRejectedValueOnce(new Error("GitHub login failed"));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByRole("button", { name: /Connect/ }));
await waitFor(() => {
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("✗ Connection failed");
expect(badge).toHaveClass("retry");
});
expect(screen.getByText("Connection failed or timed out.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
});
it("retries login from failed feedback", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
mockLoginProvider
.mockRejectedValueOnce(new Error("Initial GitHub failure"))
.mockImplementationOnce(() => new Promise(() => {}));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByRole("button", { name: /Connect/ }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockLoginProvider).toHaveBeenCalledTimes(2);
});
});
it("persists skipped status from Skip GitHub link and restores skipped feedback", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
const { unmount } = render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByText("Skip GitHub →"));
const persistedSkipCall = mockSaveOnboardingState.mock.calls.some((call) => {
return call[1]?.stepData?.github?.skipped === true;
});
expect(persistedSkipCall).toBe(true);
unmount();
const persistedState = {
currentStep: "github",
completedSteps: ["ai-setup"],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {
github: {
skipped: true,
},
},
};
mockGetOnboardingState.mockReturnValue(persistedState);
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("Skipped");
expect(badge).toHaveClass("skipped");
expect(screen.getByText(/GitHub was skipped/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Connect anyway" })).toBeTruthy();
});
it("connects from skipped feedback via Connect anyway", async () => {
const persistedState = {
currentStep: "github",
completedSteps: ["ai-setup"],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {
github: {
skipped: true,
},
},
};
mockGetOnboardingState.mockReturnValue(persistedState);
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Connect anyway" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Connect anyway" }));
await waitFor(() => {
expect(mockLoginProvider).toHaveBeenCalledWith("github");
});
});
});
it("allows navigating to First Task step via Continue without GitHub", async () => { it("allows navigating to First Task step via Continue without GitHub", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />); render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
@@ -2175,7 +2369,7 @@ describe("ModelOnboardingModal progressive disclosure", () => {
// GitHub should show Not connected // GitHub should show Not connected
expect(screen.getByTestId("onboarding-auth-status-github")).toHaveTextContent("Not connected"); expect(screen.getByTestId("onboarding-auth-status-github")).toHaveTextContent("Not connected");
expect(screen.getByTestId("onboarding-auth-status-github")).toHaveClass("auth-status-badge"); expect(screen.getByTestId("github-status-badge")).toHaveClass("auth-status-badge", "not-connected");
}); });
}); });

View File

@@ -3503,6 +3503,16 @@ body {
color: var(--color-success); color: var(--color-success);
} }
.auth-status-badge.pending {
background: color-mix(in srgb, var(--color-info) 15%, transparent);
color: var(--color-info);
}
.auth-status-badge.not-connected {
background: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
}
.auth-status-badge.skipped { .auth-status-badge.skipped {
background: color-mix(in srgb, var(--color-warning) 15%, transparent); background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning); color: var(--color-warning);
@@ -24157,6 +24167,42 @@ html .column.drag-over * {
margin: 0; margin: 0;
} }
/* === GitHub Connection Status Feedback (FN-1934) === */
.onboarding-github-feedback {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
}
.onboarding-github-feedback p {
margin: 0;
}
.onboarding-github-feedback--success {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
}
.onboarding-github-feedback--error {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
}
.onboarding-github-feedback--info {
background: color-mix(in srgb, var(--color-info) 8%, transparent);
color: var(--text-muted);
}
.onboarding-github-feedback-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
margin-top: var(--space-sm);
}
.model-onboarding-github-optional { .model-onboarding-github-optional {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -24469,6 +24515,11 @@ html .column.drag-over * {
gap: var(--space-sm); gap: var(--space-sm);
} }
.onboarding-github-feedback-actions .btn,
.onboarding-github-feedback-actions .onboarding-skip-step-link {
min-height: 36px;
}
.onboarding-provider-card { .onboarding-provider-card {
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);