feat(FN-5070): merge fusion/fn-5070
This commit is contained in:
@@ -23,10 +23,12 @@ import {
|
||||
ArchiveRestore,
|
||||
Clock,
|
||||
Settings,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
||||
import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter";
|
||||
import type { InsightCategory } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
@@ -84,6 +86,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
const [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
|
||||
|
||||
const [showModelConfig, setShowModelConfig] = useState(false);
|
||||
const [backlogHealthOnly, setBacklogHealthOnly] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(
|
||||
() => localStorage.getItem("fusion-insight-model") ?? ""
|
||||
);
|
||||
@@ -165,23 +168,41 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
[sections],
|
||||
);
|
||||
|
||||
const backlogHealthCount = useMemo(
|
||||
() => populatedSections.reduce((total, section) => total + section.items.filter(isBacklogHealthInsight).length, 0),
|
||||
[populatedSections],
|
||||
);
|
||||
|
||||
const filteredSections = useMemo(() => {
|
||||
if (!backlogHealthOnly) {
|
||||
return populatedSections;
|
||||
}
|
||||
|
||||
return populatedSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter(isBacklogHealthInsight),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}, [populatedSections, backlogHealthOnly]);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<InsightCategory | null>(null);
|
||||
|
||||
// Keep selection valid as data changes; default to first populated section.
|
||||
useEffect(() => {
|
||||
if (populatedSections.length === 0) {
|
||||
if (filteredSections.length === 0) {
|
||||
if (selectedCategory !== null) setSelectedCategory(null);
|
||||
return;
|
||||
}
|
||||
const stillExists = selectedCategory && populatedSections.some((s) => s.category === selectedCategory);
|
||||
const stillExists = selectedCategory && filteredSections.some((s) => s.category === selectedCategory && s.items.length > 0);
|
||||
if (!stillExists) {
|
||||
setSelectedCategory(populatedSections[0].category);
|
||||
setSelectedCategory(filteredSections[0].category);
|
||||
}
|
||||
}, [populatedSections, selectedCategory]);
|
||||
}, [filteredSections, selectedCategory]);
|
||||
|
||||
const activeSection: InsightSection | undefined = useMemo(
|
||||
() => populatedSections.find((s) => s.category === selectedCategory) ?? populatedSections[0],
|
||||
[populatedSections, selectedCategory],
|
||||
() => filteredSections.find((s) => s.category === selectedCategory) ?? filteredSections[0],
|
||||
[filteredSections, selectedCategory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -467,6 +488,19 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
</div>
|
||||
|
||||
<div className="insights-view-actions">
|
||||
{backlogHealthCount > 0 && (
|
||||
<button
|
||||
className={`btn btn-sm insights-backlog-health-toggle${backlogHealthOnly ? " btn-icon--active" : ""}`}
|
||||
onClick={() => setBacklogHealthOnly((prev) => !prev)}
|
||||
aria-pressed={backlogHealthOnly}
|
||||
aria-label={backlogHealthOnly ? "Show all insights" : "Show only backlog health insights"}
|
||||
data-testid="toggle-backlog-health"
|
||||
title={BACKLOG_HEALTH_TITLE_PREFIXES.join(", ")}
|
||||
>
|
||||
<Activity size={14} />
|
||||
{backlogHealthOnly ? "All Insights" : "Backlog Health"} <span>({backlogHealthCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
className="btn btn-sm insights-view-close"
|
||||
@@ -618,7 +652,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
<div className="insights-body">
|
||||
<aside className="insights-sidebar" aria-label="Insight categories">
|
||||
<ul className="insights-category-list">
|
||||
{populatedSections.map(renderCategoryItem)}
|
||||
{filteredSections.map(renderCategoryItem)}
|
||||
</ul>
|
||||
</aside>
|
||||
<div className="insights-detail">
|
||||
|
||||
@@ -72,6 +72,9 @@ vi.mock("lucide-react", () => ({
|
||||
Settings: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||
<span data-testid="settings-icon" className={className}>{`Settings-${size}`}</span>
|
||||
),
|
||||
Activity: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||
<span data-testid="activity-icon" className={className}>{`Activity-${size}`}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useInsights } from "../../hooks/useInsights";
|
||||
@@ -1067,6 +1070,159 @@ describe("InsightsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("backlog-health filter", () => {
|
||||
const backlogInsight = {
|
||||
id: "INS-BACKLOG",
|
||||
projectId: "test",
|
||||
title: "Backlog pressure detected 2026-05-18",
|
||||
content: "Backlog health content",
|
||||
category: "workflow" as const,
|
||||
status: "generated" as const,
|
||||
fingerprint: "fp-backlog",
|
||||
provenance: { trigger: "manual" as const },
|
||||
lastRunId: null,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const nonBacklogInsight = {
|
||||
id: "INS-QUALITY",
|
||||
projectId: "test",
|
||||
title: "Improve test coverage",
|
||||
content: "Quality content",
|
||||
category: "quality" as const,
|
||||
status: "generated" as const,
|
||||
fingerprint: "fp-quality",
|
||||
provenance: { trigger: "manual" as const },
|
||||
lastRunId: null,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const workflowSection = {
|
||||
category: "workflow" as const,
|
||||
label: "Workflow",
|
||||
items: [backlogInsight],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const qualitySection = {
|
||||
category: "quality" as const,
|
||||
label: "Quality",
|
||||
items: [nonBacklogInsight],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
it("hides toggle when no backlog-health insights exist", () => {
|
||||
mockUseInsights.mockReturnValue({
|
||||
sections: [qualitySection, ...mockSections],
|
||||
loading: false,
|
||||
error: null,
|
||||
latestRun: null,
|
||||
isRunInFlight: false,
|
||||
runError: null,
|
||||
refresh: vi.fn(),
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
totalCount: 1,
|
||||
dismissedCount: 0,
|
||||
});
|
||||
|
||||
render(<InsightsView {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId("toggle-backlog-health")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows toggle with count and toggles filtered/unfiltered view", async () => {
|
||||
mockUseInsights.mockReturnValue({
|
||||
sections: [qualitySection, workflowSection, ...mockSections],
|
||||
loading: false,
|
||||
error: null,
|
||||
latestRun: null,
|
||||
isRunInFlight: false,
|
||||
runError: null,
|
||||
refresh: vi.fn(),
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
totalCount: 2,
|
||||
dismissedCount: 0,
|
||||
});
|
||||
|
||||
render(<InsightsView {...defaultProps} />);
|
||||
|
||||
const toggle = screen.getByTestId("toggle-backlog-health");
|
||||
expect(toggle).toHaveTextContent("Backlog Health (1)");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByTestId("insights-category-quality")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("insights-category-workflow")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("insights-category-quality"));
|
||||
});
|
||||
expect(screen.getByTestId("insights-section-quality")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
|
||||
expect(toggle).toHaveTextContent("All Insights (1)");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByTestId("insights-category-quality")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("insights-category-workflow")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Improve test coverage")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Backlog pressure detected 2026-05-18")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByTestId("insights-category-quality")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("insights-category-workflow")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets selected category when active category has no backlog-health matches", async () => {
|
||||
mockUseInsights.mockReturnValue({
|
||||
sections: [qualitySection, workflowSection, ...mockSections],
|
||||
loading: false,
|
||||
error: null,
|
||||
latestRun: null,
|
||||
isRunInFlight: false,
|
||||
runError: null,
|
||||
refresh: vi.fn(),
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
totalCount: 2,
|
||||
dismissedCount: 0,
|
||||
});
|
||||
|
||||
render(<InsightsView {...defaultProps} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("insights-category-quality"));
|
||||
});
|
||||
expect(screen.getByTestId("insights-section-quality")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("toggle-backlog-health"));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("insights-section-workflow")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("insights-section-quality")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archived insights", () => {
|
||||
it("renders archived insights with archived class and unarchive button", () => {
|
||||
const sectionsWithArchived = [
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKLOG_HEALTH_TITLE_PREFIXES,
|
||||
isBacklogHealthInsight,
|
||||
} from "../backlog-health-filter";
|
||||
|
||||
describe("isBacklogHealthInsight", () => {
|
||||
it.each([
|
||||
"Backlog health: foo",
|
||||
"Backlog pressure detected 2026-05-18",
|
||||
"Stale paused todo surfaced [stale-paused-todo]: …",
|
||||
])("matches backlog-health prefix: %s", (title) => {
|
||||
expect(isBacklogHealthInsight({ title })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"backlog health: lowercase",
|
||||
"Some other insight",
|
||||
"",
|
||||
])("does not match non-prefixed title: %s", (title) => {
|
||||
expect(isBacklogHealthInsight({ title })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BACKLOG_HEALTH_TITLE_PREFIXES", () => {
|
||||
it("exports a non-empty readonly string array", () => {
|
||||
expect(Array.isArray(BACKLOG_HEALTH_TITLE_PREFIXES)).toBe(true);
|
||||
expect(BACKLOG_HEALTH_TITLE_PREFIXES.length).toBeGreaterThan(0);
|
||||
expect(BACKLOG_HEALTH_TITLE_PREFIXES.every((prefix) => typeof prefix === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const BACKLOG_HEALTH_TITLE_PREFIXES: readonly string[] = [
|
||||
"Backlog health:",
|
||||
"Backlog pressure detected",
|
||||
"Stale paused todo",
|
||||
];
|
||||
|
||||
export function isBacklogHealthInsight(insight: { title: string }): boolean {
|
||||
return BACKLOG_HEALTH_TITLE_PREFIXES.some((prefix) => insight.title.startsWith(prefix));
|
||||
}
|
||||
Reference in New Issue
Block a user