/** * InsightsView - Dashboard component for displaying and managing project insights * * Two-pane layout: categories on the left, insights for the selected category on the right. */ import "./InsightsView.css"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Sparkles, RefreshCw, X, Plus, AlertCircle, CheckCircle, Lightbulb, Building, Users, LineChart, TrendingUp, ExternalLink, Archive, ArchiveRestore, Clock, Settings, } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api"; import { useInsights, type InsightSection } from "../hooks/useInsights"; import type { InsightCategory } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; interface InsightsViewProps { projectId?: string; addToast: (message: string, type?: ToastType) => void; onClose?: () => void; onCreateTask?: (payload: { insightId: string; title: string; description: string }) => Promise; models?: ModelInfo[]; } const CATEGORY_ICONS: Record> = { architecture: Building, quality: CheckCircle, workflow: Clock, performance: TrendingUp, reliability: RefreshCw, security: AlertCircle, ux: Users, testability: Archive, documentation: ExternalLink, dependency: Plus, features: Lightbulb, competitive_analysis: Users, research: LineChart, trends: TrendingUp, other: Sparkles, }; export function InsightsView({ projectId, addToast, onClose, onCreateTask, models: modelsProp }: InsightsViewProps) { const { sections, loading, error, latestRun, isRunInFlight, runError, refresh, runInsights, dismiss, createTask: createTaskFromInsight, archive = async () => {}, unarchive = async () => {}, toggleShowArchived = () => {}, dismissStates, createTaskStates, archiveStates = new Map(), unarchiveStates = new Map(), totalCount, archivedCount = 0, showArchived = false, } = useInsights(projectId); const [statusMessage, setStatusMessage] = useState(null); const [statusType, setStatusType] = useState<"success" | "error" | "info">("info"); const [showModelConfig, setShowModelConfig] = useState(false); const [selectedModel, setSelectedModel] = useState( () => localStorage.getItem("fusion-insight-model") ?? "" ); // Fetch models internally if not provided via prop const [fetchedModels, setFetchedModels] = useState([]); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const [resolvedPlanningProvider, setResolvedPlanningProvider] = useState(); useEffect(() => { if (modelsProp) return; fetchModels() .then((res) => { setFetchedModels(res.models); setFavoriteProviders(res.favoriteProviders); setFavoriteModels(res.favoriteModels); setResolvedPlanningProvider(res.resolvedPlanningProvider); // Clear persisted model override if the model is no longer available const savedModel = localStorage.getItem("fusion-insight-model"); if (savedModel) { const available = res.models.some((m) => `${m.provider}/${m.id}` === savedModel); if (!available) { localStorage.removeItem("fusion-insight-model"); setSelectedModel(""); } } }) .catch(() => {}); }, [modelsProp]); const models = modelsProp ?? fetchedModels; // Auto-promote the resolved planning provider as a favorite when the user // hasn't explicitly starred any providers. This ensures the provider they // actively use always appears at the top of the dropdown. const effectiveFavoriteProviders = useMemo(() => { if (favoriteProviders.length > 0) return favoriteProviders; if (resolvedPlanningProvider) return [resolvedPlanningProvider]; return []; }, [favoriteProviders, resolvedPlanningProvider]); const handleToggleProviderFavorite = useCallback(async (provider: string) => { const isFavorite = favoriteProviders.includes(provider); const next = isFavorite ? favoriteProviders.filter((p) => p !== provider) : [provider, ...favoriteProviders]; setFavoriteProviders(next); try { await updateGlobalSettings({ favoriteProviders: next, favoriteModels }); } catch { setFavoriteProviders(favoriteProviders); } }, [favoriteProviders, favoriteModels]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { const isFavorite = favoriteModels.includes(modelId); const next = isFavorite ? favoriteModels.filter((m) => m !== modelId) : [modelId, ...favoriteModels]; setFavoriteModels(next); try { await updateGlobalSettings({ favoriteProviders, favoriteModels: next }); } catch { setFavoriteModels(favoriteModels); } }, [favoriteModels, favoriteProviders]); const handleModelChange = useCallback((value: string) => { setSelectedModel(value); if (value) { localStorage.setItem("fusion-insight-model", value); } else { localStorage.removeItem("fusion-insight-model"); } }, []); const populatedSections = useMemo( () => sections.filter((section) => section.items.length > 0), [sections], ); const [selectedCategory, setSelectedCategory] = useState(null); // Keep selection valid as data changes; default to first populated section. useEffect(() => { if (populatedSections.length === 0) { if (selectedCategory !== null) setSelectedCategory(null); return; } const stillExists = selectedCategory && populatedSections.some((s) => s.category === selectedCategory); if (!stillExists) { setSelectedCategory(populatedSections[0].category); } }, [populatedSections, selectedCategory]); const activeSection: InsightSection | undefined = useMemo( () => populatedSections.find((s) => s.category === selectedCategory) ?? populatedSections[0], [populatedSections, selectedCategory], ); useEffect(() => { if (statusMessage) { const timer = setTimeout(() => setStatusMessage(null), 5000); return () => clearTimeout(timer); } }, [statusMessage]); const handleRun = useCallback(async () => { try { setStatusMessage("Generating insights..."); setStatusType("info"); let modelProvider: string | undefined; let modelId: string | undefined; if (selectedModel) { const slashIdx = selectedModel.indexOf("/"); if (slashIdx !== -1) { const provider = selectedModel.slice(0, slashIdx); const id = selectedModel.slice(slashIdx + 1); if (provider && id) { modelProvider = provider; modelId = id; } } } await runInsights(modelProvider, modelId); setStatusMessage("Insight generation started"); setStatusType("success"); addToast("Insight generation started", "success"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to start generation"; if (message === "Insight generation is already running") { setStatusMessage("Insight generation is already running. Showing the active run."); setStatusType("info"); addToast("Insight generation is already running", "info"); return; } setStatusMessage(message); setStatusType("error"); addToast(message, "error"); } }, [runInsights, addToast, selectedModel]); const handleDismiss = useCallback( async (id: string, title: string) => { try { setStatusMessage(`Dismissing "${title}"...`); setStatusType("info"); await dismiss(id); setStatusMessage(`Dismissed "${title}"`); setStatusType("success"); addToast(`Insight dismissed: ${title}`, "success"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to dismiss insight"; setStatusMessage(message); setStatusType("error"); addToast(message, "error"); } }, [dismiss, addToast], ); const handleArchive = useCallback( async (id: string, title: string) => { try { setStatusMessage(`Archiving "${title}"...`); setStatusType("info"); await archive(id); setStatusMessage(`Archived "${title}"`); setStatusType("success"); addToast(`Insight archived: ${title}`, "success"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to archive insight"; setStatusMessage(message); setStatusType("error"); addToast(message, "error"); } }, [archive, addToast], ); const handleUnarchive = useCallback( async (id: string, title: string) => { try { setStatusMessage(`Unarchiving "${title}"...`); setStatusType("info"); await unarchive(id); setStatusMessage(`Unarchived "${title}"`); setStatusType("success"); addToast(`Insight unarchived: ${title}`, "success"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to unarchive insight"; setStatusMessage(message); setStatusType("error"); addToast(message, "error"); } }, [unarchive, addToast], ); const handleCreateTask = useCallback( async (id: string, title: string) => { try { setStatusMessage(`Creating task from "${title}"...`); setStatusType("info"); if (!onCreateTask) { throw new Error("Task creation is unavailable in this view"); } const taskData = await createTaskFromInsight(id); if (!taskData) { throw new Error("Failed to prepare task payload from insight"); } await onCreateTask({ insightId: id, title: taskData.title, description: taskData.description, }); setStatusMessage(`Task created from "${title}"`); setStatusType("success"); addToast(`Task created: ${taskData.title}`, "success"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create task"; setStatusMessage(message); setStatusType("error"); addToast(message, "error"); } }, [createTaskFromInsight, onCreateTask, addToast], ); const renderCategoryItem = (section: InsightSection) => { const IconComponent = CATEGORY_ICONS[section.category] ?? Sparkles; const isActive = activeSection?.category === section.category; return (
  • ); }; const renderActiveInsights = () => { if (!activeSection) return null; const IconComponent = CATEGORY_ICONS[activeSection.category] ?? Sparkles; return (

    {activeSection.label}

    {activeSection.items.length}
      {activeSection.items.map((insight) => { const dismissState = dismissStates.get(insight.id); const createState = createTaskStates.get(insight.id); const archiveState = archiveStates.get(insight.id); const unarchiveState = unarchiveStates.get(insight.id); const isDismissInFlight = dismissState?.running ?? false; const isCreateInFlight = createState?.running ?? false; const isArchiveInFlight = archiveState?.running ?? false; const isUnarchiveInFlight = unarchiveState?.running ?? false; const isArchived = insight.status === "archived"; const isAnyActionInFlight = activeSection.items.some( (item) => dismissStates.get(item.id)?.running || createTaskStates.get(item.id)?.running || archiveStates.get(item.id)?.running || unarchiveStates.get(item.id)?.running, ); return (
    • {insight.title}

      {isArchived ? ( ) : ( <> )}
      {insight.content && (

      {insight.content}

      )}
      {insight.status} {insight.createdAt && ( {new Date(insight.createdAt).toLocaleDateString()} )}
    • ); })}
    ); }; return (

    Insights

    {totalCount} total
    {onClose && ( )} {archivedCount > 0 && ( )}
    {showModelConfig && (
    )}
    {statusMessage && (
    {statusType === "success" && } {statusType === "error" && } {statusType === "info" && } {statusMessage}
    )}
    {runError && (
    {runError}
    )} {latestRun && (
    Latest run: {latestRun.status} {latestRun.status === "completed" && ( <> — {latestRun.insightsCreated} created, {latestRun.insightsUpdated} updated )} {latestRun.status === "failed" && latestRun.error && ( <> — {latestRun.error} )}
    )} {loading ? (

    Loading insights...

    ) : error ? (

    {error}

    ) : totalCount === 0 ? (

    No insights yet

    Generate insights to get AI-powered recommendations for your project.

    ) : (
    {renderActiveInsights()}
    )}
    ); }