feat(FN-5070): merge fusion/fn-5070

This commit is contained in:
gsxdsm
2026-05-18 16:08:10 -07:00
parent 6fc0f5cebc
commit 2a2d7b1d43
4 changed files with 238 additions and 7 deletions

View File

@@ -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">

View File

@@ -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 = [

View File

@@ -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);
});
});

View File

@@ -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));
}