Files
fusion/packages/dashboard/app/components/PostOnboardingRecommendations.tsx
gsxdsm c3d1716fdd refactor(dashboard): split monolithic styles.css into per-component files
Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).

- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
  React.lazy + Suspense; prefetch all chunks on idle so first navigation is
  instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
  635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.

- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
  so CSS regression tests load the full per-component bundle (mirroring Vite
  source order). Migrate 30+ tests off direct readFileSync('../styles.css').

- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
  imports actually inject styles in jsdom (fixes getComputedStyle assertions).

- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
  dashboard test files; points at loadAllAppCss() instead.

- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
  .form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.

- Mobile fixes along the way: scroll containment for view containers
  (min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
  mobile (with safe-area-inset for iOS home bar), AgentsView single-row
  header layout, ActivityLogModal close button on right, model-combobox
  z-index above the mobile quick-chat panel.

- Bug fix: SkillsView toggle was display:none which hid the input from the
  accessibility tree; replaced with the visually-hidden pattern so screen
  readers + getByRole still find the checkbox.

- Bug fix: standalone Delete button in TaskDetailModal for triage-column
  tasks (Actions dropdown is hidden in triage state, so previously no way
  to delete a freshly-created task without status change first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

212 lines
6.6 KiB
TypeScript

import "./PostOnboardingRecommendations.css";
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
import { AlertCircle, GitPullRequest, Key, Lightbulb, X, Zap } from "lucide-react";
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
import {
dismissPostOnboardingRecommendations,
isOnboardingCompleted,
isPostOnboardingDismissed,
} from "./model-onboarding-state";
interface PostOnboardingRecommendationsProps {
onOpenSettings: (section: string) => void;
onOpenModelOnboarding: () => void;
}
interface RecommendationItem {
id: "ai-provider" | "default-model" | "github";
title: string;
description: string;
actionLabel: string;
onAction: () => void;
icon: ComponentType<{ size?: number; className?: string; "aria-hidden"?: boolean }>;
}
export function PostOnboardingRecommendations({
onOpenSettings,
onOpenModelOnboarding,
}: PostOnboardingRecommendationsProps) {
const onboardingCompleted = isOnboardingCompleted();
const postOnboardingDismissed = isPostOnboardingDismissed();
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [dismissedLocally, setDismissedLocally] = useState(false);
const [incompleteState, setIncompleteState] = useState<{
needsAiProvider: boolean;
needsDefaultModel: boolean;
needsGitHub: boolean;
}>({
needsAiProvider: false,
needsDefaultModel: false,
needsGitHub: false,
});
useEffect(() => {
if (!onboardingCompleted || postOnboardingDismissed) {
setLoading(false);
return;
}
let cancelled = false;
const load = async () => {
try {
setLoading(true);
setHasError(false);
const [authStatus, globalSettings] = await Promise.all([
fetchAuthStatus(),
fetchGlobalSettings(),
]);
if (cancelled) {
return;
}
const providers = authStatus.providers ?? [];
const githubProvider = providers.find((provider) => provider.id === "github");
const hasAuthenticatedAiProvider = providers.some(
(provider) => provider.id !== "github" && provider.authenticated,
);
const needsAiProvider = !hasAuthenticatedAiProvider;
const needsDefaultModel = !globalSettings.defaultProvider && !globalSettings.defaultModelId;
const needsGitHub = githubProvider ? !githubProvider.authenticated : false;
setIncompleteState({ needsAiProvider, needsDefaultModel, needsGitHub });
} catch {
if (!cancelled) {
setHasError(true);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [onboardingCompleted, postOnboardingDismissed]);
const handleDismiss = useCallback(() => {
dismissPostOnboardingRecommendations();
setDismissedLocally(true);
}, []);
const handleOpenModelOnboarding = useCallback(() => {
onOpenModelOnboarding();
}, [onOpenModelOnboarding]);
const handleOpenGlobalModels = useCallback(() => {
onOpenSettings("global-models");
}, [onOpenSettings]);
const handleOpenAuthentication = useCallback(() => {
onOpenSettings("authentication");
}, [onOpenSettings]);
const recommendations = useMemo<RecommendationItem[]>(() => {
const items: RecommendationItem[] = [];
if (incompleteState.needsAiProvider) {
items.push({
id: "ai-provider",
title: "Connect AI Provider",
description: "Connect an AI provider to enable AI agents for task planning and code generation",
actionLabel: "Set Up AI",
onAction: handleOpenModelOnboarding,
icon: Zap,
});
}
if (incompleteState.needsDefaultModel) {
items.push({
id: "default-model",
title: "Select Default Model",
description: "Choose a default AI model for task execution",
actionLabel: "Choose Model",
onAction: handleOpenGlobalModels,
icon: Key,
});
}
if (incompleteState.needsGitHub) {
items.push({
id: "github",
title: "Connect GitHub",
description: "Connect GitHub to import issues and track pull requests",
actionLabel: "Connect GitHub",
onAction: handleOpenAuthentication,
icon: GitPullRequest,
});
}
return items;
}, [
incompleteState.needsAiProvider,
incompleteState.needsDefaultModel,
incompleteState.needsGitHub,
handleOpenAuthentication,
handleOpenGlobalModels,
handleOpenModelOnboarding,
]);
if (!onboardingCompleted || postOnboardingDismissed || dismissedLocally || loading || hasError || recommendations.length === 0) {
return null;
}
return (
<section
className="post-onboarding-recommendations"
role="region"
aria-label="Setup recommendations"
>
<div className="post-onboarding-recommendations__main">
<div className="post-onboarding-recommendations__icon" aria-hidden="true">
<Lightbulb size={18} aria-hidden={true} />
</div>
<div className="post-onboarding-recommendations__content">
<h2 className="post-onboarding-recommendations__title">Recommended Next Steps</h2>
<p className="post-onboarding-recommendations__description">
Complete these setup items to get the most out of Fusion.
</p>
<ul className="post-onboarding-recommendations__list">
{recommendations.map((item) => {
const ItemIcon = item.icon;
return (
<li key={item.id} className="post-onboarding-recommendations__item">
<span className="post-onboarding-recommendations__item-icon" aria-hidden="true">
<AlertCircle size={14} aria-hidden={true} />
<ItemIcon size={14} aria-hidden={true} />
</span>
<span className="post-onboarding-recommendations__item-text">
<strong>{item.title}</strong>
<span>{item.description}</span>
</span>
<button type="button" className="btn btn-sm btn-primary" onClick={item.onAction}>
{item.actionLabel}
</button>
</li>
);
})}
</ul>
</div>
</div>
<button
type="button"
className="post-onboarding-recommendations__dismiss"
onClick={handleDismiss}
aria-label="Dismiss recommendations"
>
<X size={16} aria-hidden={true} />
</button>
</section>
);
}