feat(FN-1941): surface setup readiness warnings in task entry flows

- Add useSetupReadiness hook to evaluate setup state and expose actionable warning metadata
- Add reusable SetupWarningBanner component with compact and full warning presentation modes
- Render setup warnings in NewTaskModal and QuickEntryBox so task creation surfaces missing configuration early
- Add targeted tests for the hook and banner plus integration coverage updates for modal and quick-entry behavior
This commit is contained in:
Fusion
2026-04-17 04:46:28 -07:00
committed by gsxdsm
parent c36203d162
commit b2187610cb
9 changed files with 551 additions and 0 deletions

View File

@@ -4,6 +4,8 @@ import type { ToastType } from "../hooks/useToast";
import { uploadAttachment, fetchAgents } from "../api";
import type { Agent } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type PendingImage } from "./TaskForm";
interface NewTaskModalProps {
@@ -38,6 +40,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const agentPickerRef = useRef<HTMLDivElement>(null);
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
// Handler for workflow step changes that detects explicit user interaction
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
@@ -221,6 +224,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
</div>
<div className="modal-body">
{!setupReadinessLoading && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
/>
)}
<TaskForm
mode="create"
description={description}

View File

@@ -6,6 +6,8 @@ import type { ModelInfo, RefinementType, Agent } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
const STORAGE_KEY = "kb-quick-entry-text";
@@ -150,6 +152,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// If onCreate is not provided, the component is disabled
const isDisabled = !onCreate;
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
// Fetch models and settings if not provided by parent
useEffect(() => {
@@ -1134,6 +1137,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
hidden={!showExpandedControls}
aria-hidden={!showExpandedControls}
>
{showExpandedControls && !setupReadinessLoading && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
compact
/>
)}
{/* All quick-create actions behind single disclosure toggle */}
{showExpandedControls && !isSubmitting && (
<div className="quick-entry-actions" data-testid="quick-entry-actions">

View File

@@ -0,0 +1,69 @@
interface SetupWarningBannerProps {
/** Whether an AI provider is connected */
hasAiProvider: boolean;
/** Whether GitHub is connected */
hasGithub: boolean;
/** Optional: compact mode for inline use (QuickEntryBox) */
compact?: boolean;
}
interface WarningItem {
key: "ai" | "github";
title: string;
description: string;
}
export function SetupWarningBanner({
hasAiProvider,
hasGithub,
compact = false,
}: SetupWarningBannerProps) {
if (hasAiProvider && hasGithub) {
return null;
}
if (compact) {
return (
<div
className="setup-warning-banner setup-warning-banner--compact"
role="status"
aria-live="polite"
>
<p className="setup-warning-banner__compact-text">
Setup incomplete AI and/or GitHub features will be limited.
</p>
</div>
);
}
const warningItems: WarningItem[] = [];
if (!hasAiProvider) {
warningItems.push({
key: "ai",
title: "No AI provider connected",
description:
"AI agents won't be able to work on tasks until you connect a provider. Set one up in Settings → AI Setup.",
});
}
if (!hasGithub) {
warningItems.push({
key: "github",
title: "GitHub not connected",
description:
"You won't be able to import issues from GitHub, but you can still create tasks manually.",
});
}
return (
<div className="setup-warning-banner" role="status" aria-live="polite">
{warningItems.map((warning) => (
<div key={warning.key} className="setup-warning-banner__item">
<strong className="setup-warning-banner__title">{warning.title}</strong>
<p className="setup-warning-banner__description">{warning.description}</p>
</div>
))}
</div>
);
}

View File

@@ -29,6 +29,7 @@ vi.mock("../../api", () => ({
}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
@@ -139,6 +140,32 @@ describe("NewTaskModal", () => {
});
});
it("still submits when setup warnings are shown", async () => {
const { fetchAuthStatus } = await import("../../api");
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }],
});
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByText("No AI provider connected")).toBeTruthy();
expect(screen.getByText("GitHub not connected")).toBeTruthy();
});
const descTextarea = screen.getByRole("textbox");
fireEvent.change(descTextarea, { target: { value: "Submit despite warning" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Submit despite warning",
}),
);
});
});
it("closes modal after successful creation", async () => {
const { props } = renderNewTaskModal();

View File

@@ -93,6 +93,7 @@ vi.mock("../../api", () => ({
groupOverlappingFiles: true,
autoMerge: true,
}),
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
fetchAgents: vi.fn().mockResolvedValue([]),

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SetupWarningBanner } from "../SetupWarningBanner";
describe("SetupWarningBanner", () => {
it("returns null when both hasAiProvider and hasGithub are true", () => {
const { container } = render(
<SetupWarningBanner hasAiProvider hasGithub />,
);
expect(container.firstChild).toBeNull();
});
it("shows AI provider warning when hasAiProvider is false and hasGithub is true", () => {
render(<SetupWarningBanner hasAiProvider={false} hasGithub />);
expect(screen.getByText("No AI provider connected")).toBeInTheDocument();
expect(
screen.getByText(
"AI agents won't be able to work on tasks until you connect a provider. Set one up in Settings → AI Setup.",
),
).toBeInTheDocument();
expect(screen.queryByText("GitHub not connected")).toBeNull();
});
it("shows GitHub warning when hasGithub is false and hasAiProvider is true", () => {
render(<SetupWarningBanner hasAiProvider hasGithub={false} />);
expect(screen.getByText("GitHub not connected")).toBeInTheDocument();
expect(
screen.getByText(
"You won't be able to import issues from GitHub, but you can still create tasks manually.",
),
).toBeInTheDocument();
expect(screen.queryByText("No AI provider connected")).toBeNull();
});
it("shows both warnings when both providers are missing", () => {
render(<SetupWarningBanner hasAiProvider={false} hasGithub={false} />);
expect(screen.getByText("No AI provider connected")).toBeInTheDocument();
expect(screen.getByText("GitHub not connected")).toBeInTheDocument();
});
it("compact mode renders a single-line summary", () => {
render(
<SetupWarningBanner hasAiProvider={false} hasGithub compact />,
);
expect(
screen.getByText("⚠ Setup incomplete — AI and/or GitHub features will be limited."),
).toBeInTheDocument();
expect(screen.queryByText("No AI provider connected")).toBeNull();
});
it("full mode renders setup-warning-banner class with expected structure", () => {
const { container } = render(
<SetupWarningBanner hasAiProvider={false} hasGithub={false} />,
);
const banner = container.querySelector(".setup-warning-banner");
const items = container.querySelectorAll(".setup-warning-banner__item");
expect(banner).toBeTruthy();
expect(items).toHaveLength(2);
});
it("has role=status and aria-live=polite for accessibility", () => {
render(<SetupWarningBanner hasAiProvider={false} hasGithub />);
const banner = screen.getByRole("status");
expect(banner).toHaveAttribute("aria-live", "polite");
});
});