feat(FN-1939): add first-task onboarding readiness summary

- Add a ReadinessSummary component to ModelOnboardingModal with connected/missing/skipped states
- Derive AI provider, GitHub, and default model readiness details from auth status and onboarding skip data
- Replace first-task skip banners with a unified readiness summary plus an all-integrations-connected success state
- Add token-based dashboard styles for readiness summary rows, status variants, and mobile layout adjustments
- Expand ModelOnboardingModal tests to cover readiness scenarios and verify first-task skip banners are removed
This commit is contained in:
Fusion
2026-04-17 03:59:57 -07:00
committed by gsxdsm
parent ec3e0220fe
commit 69d38b620e
3 changed files with 436 additions and 93 deletions

View File

@@ -235,6 +235,58 @@ function OnboardingDisclosure({ summary, children, className = "" }: OnboardingD
);
}
interface ReadinessItem {
label: string;
status: "connected" | "missing" | "skipped";
detail?: string;
}
interface ReadinessSummaryProps {
items: ReadinessItem[];
}
function ReadinessSummary({ items }: ReadinessSummaryProps) {
const hasAttentionItems = items.some((item) => item.status !== "connected");
if (!hasAttentionItems) {
return (
<div className="onboarding-readiness-summary" data-testid="readiness-summary" role="status">
<p className="onboarding-readiness-all-connected"> All integrations connected</p>
</div>
);
}
return (
<div className="onboarding-readiness-summary" data-testid="readiness-summary" role="status">
<p className="onboarding-readiness-header">Setup Summary</p>
{items.map((item) => {
const statusIcon =
item.status === "connected"
? "✓"
: item.status === "missing"
? "⚠"
: "○";
return (
<div
key={item.label}
className={`onboarding-readiness-item onboarding-readiness-item--${item.status}`}
data-status={item.status}
>
<span className="onboarding-readiness-icon" aria-hidden="true">
{statusIcon}
</span>
<span className="onboarding-readiness-content">
<span className="onboarding-readiness-label">{item.label}</span>
{item.detail && <span className="onboarding-readiness-detail">{item.detail}</span>}
</span>
</div>
);
})}
</div>
);
}
interface ApiKeyEntryFormProps {
provider: AuthProvider;
apiKeyInfo: ApiKeyInfo;
@@ -1165,13 +1217,88 @@ export function ModelOnboardingModal({
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);
const aiProviders = authProviders.filter((provider) => provider.id !== "github");
const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated);
const hasAiProvider = connectedAiProviders.length > 0;
// True when on GitHub step but skipped AI setup (no AI provider connected)
const aiSetupSkipped = step === "github" && !hasAiProvider;
// True when on First Task step but skipped GitHub
const githubSkipped = step === "first-task" && !isGithubAuthenticated;
const selectedModelDisplayName = (() => {
if (!selectedModel) {
return "";
}
const slashIdx = selectedModel.indexOf("/");
const providerId = slashIdx === -1 ? undefined : selectedModel.slice(0, slashIdx);
const modelId = slashIdx === -1 ? selectedModel : selectedModel.slice(slashIdx + 1);
const matchingModel = availableModels.find(
(model) => model.id === modelId && (!providerId || model.provider === providerId),
);
if (matchingModel?.name) {
return matchingModel.name;
}
if (providerId) {
return `${getProviderDisplayName(providerId)} ${modelId}`;
}
return selectedModel;
})();
const readinessItems: ReadinessItem[] = [];
if (hasAiProvider) {
const firstConnectedProviderName = getProviderDisplayName(connectedAiProviders[0]?.id ?? "");
readinessItems.push({
label: "AI Provider",
status: "connected",
detail: `${firstConnectedProviderName} connected — AI agents can work on tasks`,
});
} else if (
aiProviders.length > 0
&& aiProviders.some((provider) => skippedProviders[provider.id])
) {
readinessItems.push({
label: "AI Provider",
status: "skipped",
detail: "AI agents won't be available until you connect a provider",
});
} else {
readinessItems.push({
label: "AI Provider",
status: "missing",
detail: "Connect a provider in Settings → AI Setup",
});
}
if (isGithubAuthenticated) {
readinessItems.push({
label: "GitHub",
status: "connected",
detail: "Issues and PRs can be imported",
});
} else if (!hasGithubProvider || isGithubSkipped) {
readinessItems.push({
label: "GitHub",
status: "skipped",
detail: "You can connect anytime from Settings",
});
} else {
readinessItems.push({
label: "GitHub",
status: "missing",
detail: "Connect to import issues as tasks",
});
}
if (selectedModelDisplayName) {
readinessItems.push({
label: "Default Model",
status: "connected",
detail: selectedModelDisplayName,
});
}
return (
<div
@@ -1693,27 +1820,7 @@ export function ModelOnboardingModal({
Your workspace is ready. Here's how to get started:
</p>
{/* Skip-state banner: shown when GitHub was skipped */}
{githubSkipped && (
<div className="onboarding-skip-banner" role="status">
<strong>GitHub not connected</strong>
<p>
You won&apos;t be able to import issues from GitHub, but you can still create tasks manually.
Connect GitHub later from Settings.
</p>
</div>
)}
{/* Skip-state banner: shown when AI setup was also skipped */}
{!hasAiProvider && (
<div className="onboarding-skip-banner" role="status">
<strong>No AI provider connected</strong>
<p>
AI agents won&apos;t be able to work on tasks until you connect a provider.
Set one up later in Settings → AI Setup.
</p>
</div>
)}
<ReadinessSummary items={readinessItems} />
<OnboardingDisclosure summary="What happens when I create a task?">
<p className="onboarding-helper-text">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { ModelOnboardingModal } from "../ModelOnboardingModal";
import type { AuthProvider } from "../../api";
@@ -2396,7 +2396,8 @@ describe("ModelOnboardingModal", () => {
vi.useRealTimers();
});
});
});
});
describe("ModelOnboardingModal progressive disclosure", () => {
@@ -2864,7 +2865,7 @@ describe("ModelOnboardingModal progressive disclosure", () => {
// Should show skip banner about AI provider
const banners = screen.getAllByRole("status");
expect(banners.some(b => b.classList.contains("onboarding-skip-banner"))).toBe(true);
expect(banners.some((b) => b.classList.contains("onboarding-skip-banner"))).toBe(true);
const skipBanner = screen.getByText("No AI provider connected").closest(".onboarding-skip-banner");
expect(skipBanner).toBeTruthy();
@@ -2888,70 +2889,6 @@ describe("ModelOnboardingModal progressive disclosure", () => {
expect(screen.queryByText("No AI provider connected")).toBeNull();
});
it("shows GitHub skip banner on First Task step when GitHub not connected", async () => {
// AI provider connected but GitHub not connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Should show skip banner about GitHub
const skipBanner = screen.getByText("GitHub not connected").closest(".onboarding-skip-banner");
expect(skipBanner).toBeTruthy();
expect(skipBanner).toHaveTextContent(/won't be able to import issues from GitHub/);
});
it("does not show GitHub skip banner on First Task step when GitHub is connected", async () => {
// Both AI and GitHub connected - mock for all auth status calls
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Should NOT show skip banner about GitHub (use querySelector to avoid matching the auth badge)
const skipBanners = document.querySelectorAll(".onboarding-skip-banner");
const githubSkipBanner = Array.from(skipBanners).find(
(banner) => banner.textContent?.includes("GitHub not connected")
);
expect(githubSkipBanner).toBeUndefined();
});
it("shows both skip banners on First Task step when both AI and GitHub are skipped", async () => {
// Neither AI nor GitHub connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Should show BOTH skip banners
expect(screen.getByText("GitHub not connected")).toBeTruthy();
expect(screen.getByText("No AI provider connected")).toBeTruthy();
// Both should have the skip-banner class
const githubBanner = screen.getByText("GitHub not connected").closest(".onboarding-skip-banner");
const aiBanner = screen.getByText("No AI provider connected").closest(".onboarding-skip-banner");
expect(githubBanner).toBeTruthy();
expect(aiBanner).toBeTruthy();
});
it("does not show any skip banner on AI Setup step", async () => {
// No providers connected
mockFetchAuthStatus.mockResolvedValueOnce({
@@ -2990,4 +2927,194 @@ describe("ModelOnboardingModal progressive disclosure", () => {
expect(skipBanner).toHaveAttribute("role", "status");
});
});
describe("First-task readiness summary (FN-1939)", () => {
const setFirstTaskState = (stepData: Record<string, unknown> = {}) => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",
updatedAt: new Date().toISOString(),
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
dismissed: false,
completed: false,
completedAt: undefined,
stepData,
});
};
const getReadinessItem = (label: string): HTMLElement => {
const readinessSummary = screen.getByTestId("readiness-summary");
const row = within(readinessSummary)
.getByText(label)
.closest(".onboarding-readiness-item");
expect(row).toBeTruthy();
return row as HTMLElement;
};
it("shows all-connected message when everything is set up", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const readinessSummary = screen.getByTestId("readiness-summary");
expect(within(readinessSummary).getByText(/All integrations connected/)).toBeTruthy();
expect(readinessSummary.querySelectorAll(".onboarding-readiness-item")).toHaveLength(0);
});
it("shows AI provider as missing when no provider is connected", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const aiProviderItem = getReadinessItem("AI Provider");
expect(aiProviderItem).toHaveAttribute("data-status", "missing");
expect(aiProviderItem).toHaveClass("onboarding-readiness-item--missing");
expect(aiProviderItem).toHaveTextContent(/Connect a provider in Settings/i);
});
it("shows AI provider as skipped when explicitly skipped", async () => {
setFirstTaskState({
"ai-setup": {
skippedProviders: { anthropic: true },
},
});
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const aiProviderItem = getReadinessItem("AI Provider");
expect(aiProviderItem).toHaveAttribute("data-status", "skipped");
expect(aiProviderItem).toHaveTextContent(/AI agents won't be available/i);
});
it("shows GitHub as missing when available but not connected", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const githubItem = getReadinessItem("GitHub");
expect(githubItem).toHaveAttribute("data-status", "missing");
expect(githubItem).toHaveTextContent(/import issues as tasks/i);
});
it("shows GitHub as skipped when no GitHub provider is available", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const githubItem = getReadinessItem("GitHub");
expect(githubItem).toHaveAttribute("data-status", "skipped");
expect(githubItem).toHaveTextContent(/connect anytime from Settings/i);
});
it("shows default model when selected", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
mockFetchGlobalSettings.mockResolvedValueOnce({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
const modelItem = getReadinessItem("Default Model");
expect(modelItem).toHaveAttribute("data-status", "connected");
expect(modelItem).toHaveTextContent("Claude Sonnet 4.5");
});
it("hides default model item when no model is selected", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
expect(screen.queryByText("Default Model")).toBeNull();
});
it("removes first-task skip banners in favor of readiness summary", async () => {
setFirstTaskState();
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
expect(screen.getByTestId("readiness-summary")).toBeTruthy();
expect(document.querySelectorAll(".onboarding-skip-banner")).toHaveLength(0);
});
});
});