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:
@@ -284,6 +284,9 @@ export type LoginOutcome = "pending" | "success" | "timeout" | "failed" | "cance
|
||||
/** Provider connection status for UI display */
|
||||
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) */
|
||||
const MAX_POLL_CYCLES = 150;
|
||||
|
||||
@@ -322,6 +325,10 @@ export function ModelOnboardingModal({
|
||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
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);
|
||||
|
||||
// Initialize skippedProviders from persisted state
|
||||
@@ -408,6 +415,11 @@ export function ModelOnboardingModal({
|
||||
aiSetupReturnRef.current = step !== "ai-setup";
|
||||
}, [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
|
||||
const getProviderStatus = useCallback((provider: AuthProvider): ProviderConnectionStatus => {
|
||||
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
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
@@ -517,11 +569,6 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -554,6 +601,25 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
}, [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
|
||||
const handleLogin = useCallback(
|
||||
async (providerId: string) => {
|
||||
@@ -603,6 +669,9 @@ export function ModelOnboardingModal({
|
||||
}
|
||||
setAuthActionInProgress(null);
|
||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
|
||||
if (providerId === "github") {
|
||||
setGitHubSkippedState(false);
|
||||
}
|
||||
addToast("Login successful", "success");
|
||||
}
|
||||
} catch {
|
||||
@@ -625,7 +694,7 @@ export function ModelOnboardingModal({
|
||||
setAuthActionInProgress(null);
|
||||
}
|
||||
},
|
||||
[addToast],
|
||||
[addToast, setGitHubSkippedState],
|
||||
);
|
||||
|
||||
// Cancellation handler for in-progress logins
|
||||
@@ -893,6 +962,8 @@ export function ModelOnboardingModal({
|
||||
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
|
||||
const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github");
|
||||
|
||||
const githubStatus = getGitHubStatus();
|
||||
|
||||
// Skip-state detection: derived state for informational banners
|
||||
// Detects whether at least one AI provider is connected (excludes GitHub)
|
||||
const hasAiProvider = authProviders.some((p) => p.id !== "github" && p.authenticated);
|
||||
@@ -1307,13 +1378,8 @@ export function ModelOnboardingModal({
|
||||
<GitPullRequest size={16} style={{ marginRight: 8 }} />
|
||||
GitHub
|
||||
</strong>
|
||||
<span
|
||||
data-testid="onboarding-auth-status-github"
|
||||
className={`auth-status-badge ${isGithubAuthenticated ? "connected" : "not-connected"}`}
|
||||
>
|
||||
{isGithubAuthenticated
|
||||
? "✓ Connected"
|
||||
: "Not connected"}
|
||||
<span data-testid="onboarding-auth-status-github">
|
||||
<GitHubStatusBadge status={githubStatus} />
|
||||
</span>
|
||||
</div>
|
||||
{isGithubAuthenticated && (
|
||||
@@ -1332,7 +1398,7 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isGithubAuthenticated && (
|
||||
{(githubStatus === "not-connected" || githubStatus === "pending") && (
|
||||
<div className="onboarding-github-connect-cta" data-testid="onboarding-github-connect-cta">
|
||||
{authActionInProgress === "github" ? (
|
||||
<div className="onboarding-github-connect-actions">
|
||||
@@ -1355,23 +1421,56 @@ export function ModelOnboardingModal({
|
||||
Connect
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!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">
|
||||
No worries if you're not ready — connect GitHub anytime from Settings → Authentication.
|
||||
</p>
|
||||
@@ -1490,7 +1589,7 @@ export function ModelOnboardingModal({
|
||||
<button className="btn btn-sm" onClick={handleBack}>
|
||||
← Back
|
||||
</button>
|
||||
<button className="onboarding-skip-step-link" onClick={handleNext}>
|
||||
<button className="onboarding-skip-step-link" onClick={handleSkipGitHubStep}>
|
||||
Skip GitHub →
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleNext}>
|
||||
|
||||
@@ -540,6 +540,200 @@ describe("ModelOnboardingModal", () => {
|
||||
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 () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -2175,7 +2369,7 @@ describe("ModelOnboardingModal progressive disclosure", () => {
|
||||
|
||||
// GitHub should show 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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3503,6 +3503,16 @@ body {
|
||||
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 {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
@@ -24157,6 +24167,42 @@ html .column.drag-over * {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -24469,6 +24515,11 @@ html .column.drag-over * {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.onboarding-github-feedback-actions .btn,
|
||||
.onboarding-github-feedback-actions .onboarding-skip-step-link {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.onboarding-provider-card {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
|
||||
Reference in New Issue
Block a user