feat(FN-2154): show dismissible setup warning on project page
- Render SetupWarningBanner from App only in project view when setup readiness reports warnings - Persist dismiss state per project via scoped storage and restore it when switching projects - Add optional dismiss action to SetupWarningBanner with close button UI and responsive styles - Remove inline banner from QuickEntryBox and add tests covering dismiss button behavior
This commit is contained in:
@@ -19,6 +19,7 @@ import { AppModals } from "./components/AppModals";
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
||||
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import { useAppSettings } from "./hooks/useAppSettings";
|
||||
import { useDeepLink } from "./hooks/useDeepLink";
|
||||
import { useFavorites } from "./hooks/useFavorites";
|
||||
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
@@ -50,6 +52,9 @@ import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchAiSession, fetchUnreadCount, reportDashboardPerf } from "./api";
|
||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||
|
||||
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
|
||||
|
||||
function AppInner() {
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
@@ -59,7 +64,13 @@ function AppInner() {
|
||||
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects, register: registerProject, update: updateProjectHook, unregister: unregisterProjectHook } = useProjects();
|
||||
const { nodes } = useNodes();
|
||||
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
|
||||
|
||||
const {
|
||||
hasAiProvider,
|
||||
hasGithub,
|
||||
loading: setupReadinessLoading,
|
||||
hasWarnings,
|
||||
} = useSetupReadiness(currentProject?.id);
|
||||
|
||||
// Node context for local/remote node switching
|
||||
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
|
||||
|
||||
@@ -203,6 +214,20 @@ function AppInner() {
|
||||
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
|
||||
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [quickChatOpen, setQuickChatOpen] = useState(false);
|
||||
const [setupWarningDismissed, setSetupWarningDismissed] = useState(
|
||||
() => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSetupWarningDismissed(
|
||||
getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
|
||||
);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const handleDismissSetupWarning = useCallback(() => {
|
||||
setScopedItem(SETUP_WARNING_DISMISSED_KEY, "true", currentProject?.id);
|
||||
setSetupWarningDismissed(true);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
// Settings state
|
||||
const {
|
||||
@@ -665,6 +690,13 @@ function AppInner() {
|
||||
onOpenSettings={(section) => modalManager.openSettings(section as SectionId)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && (
|
||||
<SetupWarningBanner
|
||||
hasAiProvider={hasAiProvider}
|
||||
hasGithub={hasGithub}
|
||||
onDismiss={handleDismissSetupWarning}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile ? " project-content--with-mobile-nav" : ""}`}
|
||||
>
|
||||
|
||||
@@ -6,8 +6,6 @@ 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";
|
||||
@@ -152,7 +150,6 @@ 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(() => {
|
||||
@@ -1137,14 +1134,6 @@ 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">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface SetupWarningBannerProps {
|
||||
/** Whether an AI provider is connected */
|
||||
hasAiProvider: boolean;
|
||||
@@ -5,6 +7,8 @@ interface SetupWarningBannerProps {
|
||||
hasGithub: boolean;
|
||||
/** Optional: compact mode for inline use (QuickEntryBox) */
|
||||
compact?: boolean;
|
||||
/** Optional callback to dismiss the banner */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
interface WarningItem {
|
||||
@@ -17,21 +21,34 @@ export function SetupWarningBanner({
|
||||
hasAiProvider,
|
||||
hasGithub,
|
||||
compact = false,
|
||||
onDismiss,
|
||||
}: SetupWarningBannerProps) {
|
||||
if (hasAiProvider && hasGithub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dismissButton = onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
className="setup-warning-banner__dismiss touch-target"
|
||||
aria-label="Dismiss setup warning"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className="setup-warning-banner setup-warning-banner--compact"
|
||||
className={`setup-warning-banner setup-warning-banner--compact${onDismiss ? " setup-warning-banner--dismissible" : ""}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="setup-warning-banner__compact-text">
|
||||
⚠ Setup incomplete — AI and/or GitHub features will be limited.
|
||||
</p>
|
||||
{dismissButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +74,12 @@ export function SetupWarningBanner({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-warning-banner" role="status" aria-live="polite">
|
||||
<div
|
||||
className={`setup-warning-banner${onDismiss ? " setup-warning-banner--dismissible" : ""}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{dismissButton}
|
||||
{warningItems.map((warning) => (
|
||||
<div key={warning.key} className="setup-warning-banner__item">
|
||||
<strong className="setup-warning-banner__title">{warning.title}</strong>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { SetupWarningBanner } from "../SetupWarningBanner";
|
||||
|
||||
describe("SetupWarningBanner", () => {
|
||||
@@ -71,4 +71,69 @@ describe("SetupWarningBanner", () => {
|
||||
const banner = screen.getByRole("status");
|
||||
expect(banner).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("does not render dismiss button when onDismiss is not provided", () => {
|
||||
render(<SetupWarningBanner hasAiProvider={false} hasGithub />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Dismiss setup warning" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders dismiss button when onDismiss is provided (full mode)", () => {
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWarningBanner
|
||||
hasAiProvider={false}
|
||||
hasGithub
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByRole("button", {
|
||||
name: "Dismiss setup warning",
|
||||
});
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(dismissButton);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders dismiss button when onDismiss is provided (compact mode)", () => {
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWarningBanner
|
||||
hasAiProvider={false}
|
||||
hasGithub
|
||||
compact
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByRole("button", {
|
||||
name: "Dismiss setup warning",
|
||||
});
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(dismissButton);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clicking dismiss button calls onDismiss", () => {
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWarningBanner
|
||||
hasAiProvider={false}
|
||||
hasGithub={false}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss setup warning" }));
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24338,12 +24338,50 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
.setup-warning-banner--compact {
|
||||
gap: 0;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.setup-warning-banner--dismissible:not(.setup-warning-banner--compact) {
|
||||
position: relative;
|
||||
padding-inline-end: calc(var(--space-2xl) + var(--space-lg) + var(--space-xs));
|
||||
}
|
||||
|
||||
.setup-warning-banner--dismissible:not(.setup-warning-banner--compact) .setup-warning-banner__dismiss {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
inset-inline-end: var(--space-sm);
|
||||
}
|
||||
|
||||
.setup-warning-banner__compact-text {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setup-warning-banner__dismiss {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setup-warning-banner__dismiss:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
}
|
||||
|
||||
.setup-warning-banner__dismiss:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -24352,6 +24390,17 @@ html .column.drag-over * {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.setup-warning-banner--dismissible:not(.setup-warning-banner--compact) {
|
||||
padding-inline-end: var(--space-md);
|
||||
}
|
||||
|
||||
.setup-warning-banner--dismissible:not(.setup-warning-banner--compact) .setup-warning-banner__dismiss {
|
||||
position: static;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* === OnboardingDisclosure === */
|
||||
.onboarding-disclosure {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user