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:
@@ -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'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'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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user