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:
@@ -5,6 +5,7 @@ import { Board } from "./components/Board";
|
||||
import { ListView } from "./components/ListView";
|
||||
import { ProjectOverview } from "./components/ProjectOverview";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { InsightsView } from "./components/InsightsView";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
import { NodesView } from "./components/NodesView";
|
||||
import { ChatView } from "./components/ChatView";
|
||||
@@ -403,6 +404,18 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "insights") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<InsightsView
|
||||
projectId={currentProject?.id}
|
||||
addToast={addToast}
|
||||
onClose={() => handleChangeTaskView("board")}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "board") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
@@ -579,7 +592,7 @@ function AppInner() {
|
||||
onOpenQuickChat={() => setQuickChatOpen(true)}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && (
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && (
|
||||
<QuickChatFAB
|
||||
projectId={currentProject.id}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -164,4 +164,134 @@ describe("useViewState", () => {
|
||||
expect(openSetupWizard).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Insights view persistence ─────────────────────────────────────
|
||||
|
||||
it("reads saved insights taskView from scoped localStorage on init", async () => {
|
||||
// Set up scoped storage for project
|
||||
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "insights");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("insights");
|
||||
});
|
||||
});
|
||||
|
||||
it("persists insights taskView changes to scoped localStorage", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setTaskView("insights");
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("insights");
|
||||
});
|
||||
|
||||
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
|
||||
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
||||
|
||||
for (const view of legacyViews) {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(`kb:proj_123:kb-dashboard-task-view`, view);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe(view);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Project-switch scoped rehydration ─────────────────────────────
|
||||
|
||||
it("project A reads its own scoped task-view and project B reads its own", async () => {
|
||||
const projectA: ProjectInfo = { ...PROJECT, id: "proj_a", name: "Project A" };
|
||||
const projectB: ProjectInfo = { ...PROJECT, id: "proj_b", name: "Project B" };
|
||||
|
||||
// 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");
|
||||
|
||||
// Start with project A
|
||||
const { result, rerender } = renderHook(
|
||||
({ project }) => useViewState(createOptions({ currentProject: project })),
|
||||
{ initialProps: { project: projectA } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("insights");
|
||||
});
|
||||
|
||||
// Switch to project B
|
||||
rerender({ project: projectB });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("agents");
|
||||
});
|
||||
|
||||
// Switch back to project A - should restore A's view
|
||||
rerender({ project: projectA });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("insights");
|
||||
});
|
||||
});
|
||||
|
||||
it("no cross-project bleed when switching projects", async () => {
|
||||
const projectA: ProjectInfo = { ...PROJECT, id: "proj_a", name: "Project A" };
|
||||
const projectB: ProjectInfo = { ...PROJECT, id: "proj_b", name: "Project B" };
|
||||
|
||||
// Only set view for project A, project B has no saved view
|
||||
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
|
||||
// Ensure project B has no scoped storage
|
||||
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
|
||||
|
||||
// Load project A
|
||||
const { result: resultA } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: projectA,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resultA.current.taskView).toBe("insights");
|
||||
});
|
||||
|
||||
// Load project B (no saved view - should default to board)
|
||||
const { result: resultB } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: projectB,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resultB.current.taskView).toBe("board");
|
||||
});
|
||||
|
||||
// Project A's view should still be insights (not affected by project B load)
|
||||
expect(resultA.current.taskView).toBe("insights");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills" | "mailbox" | "insights";
|
||||
|
||||
interface UseViewStateOptions {
|
||||
projectsLoading: boolean;
|
||||
@@ -48,7 +48,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
|
||||
const [taskView, setTaskView] = useState<TaskView>(() => {
|
||||
const saved = getScopedItem("kb-dashboard-task-view");
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills" || saved === "mailbox") return saved as TaskView;
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills" || saved === "mailbox" || saved === "insights") return saved as TaskView;
|
||||
return "board";
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills" || saved === "mailbox") {
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills" || saved === "mailbox" || saved === "insights") {
|
||||
setTaskView(saved as TaskView);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user