feat(FN-5070): merge fusion/fn-5070
This commit is contained in:
@@ -23,10 +23,12 @@ import {
|
|||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
Clock,
|
Clock,
|
||||||
Settings,
|
Settings,
|
||||||
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
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 { InsightCategory } from "@fusion/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
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 [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
|
||||||
|
|
||||||
const [showModelConfig, setShowModelConfig] = useState(false);
|
const [showModelConfig, setShowModelConfig] = useState(false);
|
||||||
|
const [backlogHealthOnly, setBacklogHealthOnly] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>(
|
const [selectedModel, setSelectedModel] = useState<string>(
|
||||||
() => localStorage.getItem("fusion-insight-model") ?? ""
|
() => localStorage.getItem("fusion-insight-model") ?? ""
|
||||||
);
|
);
|
||||||
@@ -165,23 +168,41 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
[sections],
|
[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);
|
const [selectedCategory, setSelectedCategory] = useState<InsightCategory | null>(null);
|
||||||
|
|
||||||
// Keep selection valid as data changes; default to first populated section.
|
// Keep selection valid as data changes; default to first populated section.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (populatedSections.length === 0) {
|
if (filteredSections.length === 0) {
|
||||||
if (selectedCategory !== null) setSelectedCategory(null);
|
if (selectedCategory !== null) setSelectedCategory(null);
|
||||||
return;
|
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) {
|
if (!stillExists) {
|
||||||
setSelectedCategory(populatedSections[0].category);
|
setSelectedCategory(filteredSections[0].category);
|
||||||
}
|
}
|
||||||
}, [populatedSections, selectedCategory]);
|
}, [filteredSections, selectedCategory]);
|
||||||
|
|
||||||
const activeSection: InsightSection | undefined = useMemo(
|
const activeSection: InsightSection | undefined = useMemo(
|
||||||
() => populatedSections.find((s) => s.category === selectedCategory) ?? populatedSections[0],
|
() => filteredSections.find((s) => s.category === selectedCategory) ?? filteredSections[0],
|
||||||
[populatedSections, selectedCategory],
|
[filteredSections, selectedCategory],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -467,6 +488,19 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="insights-view-actions">
|
<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 && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm insights-view-close"
|
className="btn btn-sm insights-view-close"
|
||||||
@@ -618,7 +652,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
<div className="insights-body">
|
<div className="insights-body">
|
||||||
<aside className="insights-sidebar" aria-label="Insight categories">
|
<aside className="insights-sidebar" aria-label="Insight categories">
|
||||||
<ul className="insights-category-list">
|
<ul className="insights-category-list">
|
||||||
{populatedSections.map(renderCategoryItem)}
|
{filteredSections.map(renderCategoryItem)}
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="insights-detail">
|
<div className="insights-detail">
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ vi.mock("lucide-react", () => ({
|
|||||||
Settings: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
Settings: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||||
<span data-testid="settings-icon" className={className}>{`Settings-${size}`}</span>
|
<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";
|
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", () => {
|
describe("archived insights", () => {
|
||||||
it("renders archived insights with archived class and unarchive button", () => {
|
it("renders archived insights with archived class and unarchive button", () => {
|
||||||
const sectionsWithArchived = [
|
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