feat(FN-1709): add InsightsView navigation and routing integration

- Add 'insights' to TaskView type for view state persistence
- Add InsightsView routing in App.tsx with scoped persistence
- Add Insights nav item to Header with icon and keyboard shortcut
- Add Insights nav item to MobileNavBar
- Add comprehensive tests for App, Header, and MobileNavBar view switching
- Add useViewState tests for scoped persistence
This commit is contained in:
Fusion
2026-04-15 16:58:26 -07:00
committed by gsxdsm
parent c18608c7e6
commit 9d42dbe93c
8 changed files with 332 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles } from "lucide-react";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
@@ -180,8 +180,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox") => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox" | "insights";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox" | "insights") => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
@@ -728,6 +728,15 @@ export function Header({
>
<Map size={16} />
</button>
<button
className={`view-toggle-btn${view === "insights" ? " active" : ""}`}
onClick={() => onChangeView("insights")}
title="Insights view"
aria-label="Insights view"
aria-pressed={view === "insights"}
>
<Sparkles size={16} />
</button>
</div>
)}

View File

@@ -16,6 +16,7 @@ import {
MoreHorizontal,
Play,
Settings,
Sparkles,
Target,
Terminal,
Workflow,
@@ -27,9 +28,9 @@ import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox";
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox" | "insights";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox") => void;
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox" | "insights") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
@@ -263,6 +264,18 @@ export function MobileNavBar({
<span className="mobile-nav-tab-label">Roadmaps</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "insights" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-insights"
role="tab"
aria-selected={view === "insights"}
onClick={() => onChangeView("insights")}
>
<Sparkles />
<span className="mobile-nav-tab-label">Insights</span>
</button>
<button
type="button"
className="mobile-nav-tab"
@@ -498,6 +511,16 @@ export function MobileNavBar({
<span>Roadmaps</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-insights"
onClick={() => handleMoreAction(() => onChangeView("insights"))}
>
<Sparkles />
<span>Insights</span>
</button>
<div className="mobile-more-separator" />
<button

View File

@@ -1215,6 +1215,86 @@ describe("App view switching", () => {
localStorage.removeItem(taskViewStorageKey());
});
// ── Insights View ──────────────────────────────────────────────────
it("renders InsightsView when insights view is selected", async () => {
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("Insights view")).toBeTruthy();
});
// Click to switch to insights view
fireEvent.click(screen.getByTitle("Insights view"));
// Insights view should be rendered (it has a insights-view container)
await waitFor(() => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
// Should NOT show board, list, or agents view
expect(document.querySelector(".board")).toBeNull();
expect(document.querySelector(".list-view")).toBeNull();
expect(document.querySelector(".agents-view")).toBeNull();
});
it("persists insights view preference to localStorage", async () => {
localStorage.removeItem(taskViewStorageKey());
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Insights view")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Insights view"));
await waitFor(() => {
expect(localStorage.getItem(taskViewStorageKey())).toBe("insights");
});
});
it("initializes insights view from localStorage if saved", async () => {
localStorage.setItem(taskViewStorageKey(), "insights");
render(<App />);
await waitFor(() => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
expect(screen.getByTitle("Insights view").className).toContain("active");
localStorage.removeItem(taskViewStorageKey());
});
it("project switch rehydrates each project's own scoped task-view", async () => {
const projectA = { id: "proj_a", name: "Project A", path: "/a", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
const projectB = { id: "proj_b", name: "Project B", path: "/b", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
// Set different views for each project
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
localStorage.setItem("kb:proj_b:kb-dashboard-task-view", "agents");
mockProjectsState.projects = [projectA, projectB];
mockCurrentProjectState.currentProject = projectA;
render(<App />);
// Wait for project A's insights view to load
await waitFor(() => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
// Verify insights is active
expect(screen.getByTitle("Insights view").className).toContain("active");
// Cleanup
localStorage.removeItem("kb:proj_a:kb-dashboard-task-view");
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
});
});
describe("App GitHub import", () => {

View File

@@ -460,6 +460,39 @@ describe("Header", () => {
expect(roadmapsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Insights View Toggle ─────────────────────────────────────────
it("renders insights view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const insightsBtn = screen.getByTitle("Insights view");
expect(insightsBtn).toBeDefined();
});
it("calls onChangeView with 'insights' when insights view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const insightsBtn = screen.getByTitle("Insights view");
fireEvent.click(insightsBtn);
expect(onChangeView).toHaveBeenCalledWith("insights");
});
it("marks insights view button as active when view is 'insights'", () => {
const onChangeView = vi.fn();
render(<Header view="insights" onChangeView={onChangeView} />);
const insightsBtn = screen.getByTitle("Insights view");
expect(insightsBtn.className).toContain("active");
expect(insightsBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark insights view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const insightsBtn = screen.getByTitle("Insights view");
expect(insightsBtn.className).not.toContain("active");
expect(insightsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Search Visibility by View ─────────────────────────────────────
it("shows search toggle when view is 'board' on desktop", () => {

View File

@@ -56,7 +56,7 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders eight tab buttons (tasks + agents + missions + chat + mailbox + skills + roadmaps + more)", () => {
it("renders nine tab buttons (tasks + agents + missions + chat + mailbox + skills + roadmaps + insights + more)", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeDefined();
@@ -66,6 +66,7 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-insights")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
@@ -169,6 +170,26 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-skills").className).not.toContain("mobile-nav-tab--active");
});
// ── Insights tab ──────────────────────────────────────────────────
it("insights tab calls onChangeView with 'insights'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-insights"));
expect(props.onChangeView).toHaveBeenCalledWith("insights");
});
it("insights tab is active when view is 'insights'", () => {
render(<MobileNavBar {...createDefaultProps()} view="insights" />);
expect(screen.getByTestId("mobile-nav-tab-insights").className).toContain("mobile-nav-tab--active");
});
it("insights tab is not active when view is 'board'", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" />);
expect(screen.getByTestId("mobile-nav-tab-insights").className).not.toContain("mobile-nav-tab--active");
});
it("opens and toggles the more sheet", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
@@ -196,9 +217,22 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-projects")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-chat")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-insights")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("insights item in more sheet calls onChangeView with 'insights'", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-insights"));
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
expect(props.onChangeView).toHaveBeenCalledWith("insights");
});
it("activity log item in more sheet calls onOpenActivityLog", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);