feat(insights): add collapsible model selector to InsightsView with gear trigger
Adds a Settings gear button to the InsightsView action bar that toggles a collapsible model configuration row. The row contains a CustomModelDropdown allowing users to override the insight generation model. Selections persist to localStorage under the "fusion-insight-model" key. A yellow indicator dot appears on the gear when a non-default model is active. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
146
packages/dashboard/app/__tests__/insight-model-selector.test.tsx
Normal file
146
packages/dashboard/app/__tests__/insight-model-selector.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Insight Model Selector — TDD Red Phase
|
||||
*
|
||||
* These tests define the contract for the collapsible model-selector gear button
|
||||
* on InsightsView. They are expected to FAIL until the UI is implemented (Task 4).
|
||||
*
|
||||
* Contract:
|
||||
* 1. Gear icon button (data-testid="toggle-model-config") toggles a config row.
|
||||
* 2. Config row (data-testid="model-config") is hidden by default.
|
||||
* 3. CustomModelDropdown (data-testid="model-dropdown") appears inside the row.
|
||||
* 4. Selected model persists to localStorage key "fusion-insight-model".
|
||||
* 5. On mount, the stored model is restored into the dropdown.
|
||||
* 6. A yellow indicator dot (class "insights-model-indicator") appears on the gear
|
||||
* when a non-default model is selected.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
|
||||
import { InsightsView } from "../components/InsightsView";
|
||||
|
||||
// Register jest-dom matchers (setup files not running in this environment)
|
||||
expect.extend(jestDomMatchers);
|
||||
|
||||
// Ensure localStorage is available (jsdom in this environment may not provide it)
|
||||
const localStorageStore: Record<string, string> = {};
|
||||
beforeAll(() => {
|
||||
if (typeof localStorage === "undefined" || typeof localStorage.clear !== "function") {
|
||||
const mock = {
|
||||
getItem: (key: string) => localStorageStore[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageStore[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete localStorageStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(localStorageStore).length;
|
||||
},
|
||||
key: (index: number) => Object.keys(localStorageStore)[index] ?? null,
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { value: mock, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Mock useInsights hook
|
||||
vi.mock("../hooks/useInsights", () => ({
|
||||
useInsights: () => ({
|
||||
sections: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
latestRun: null,
|
||||
isRunInFlight: false,
|
||||
runError: null,
|
||||
refresh: vi.fn(),
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
toggleShowArchived: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
archiveStates: new Map(),
|
||||
unarchiveStates: new Map(),
|
||||
totalCount: 0,
|
||||
dismissedCount: 0,
|
||||
archivedCount: 0,
|
||||
showArchived: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown since it has complex portal behavior
|
||||
vi.mock("../components/CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({ value, onChange, placeholder }: any) => (
|
||||
<div data-testid="model-dropdown">
|
||||
<span data-testid="model-value">{value || placeholder}</span>
|
||||
<button data-testid="model-change" onClick={() => onChange("openai/gpt-4o")} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
describe("Insight model selector", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders a gear button to toggle model config", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
expect(screen.getByTestId("toggle-model-config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show model config row by default", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
expect(screen.queryByTestId("model-config")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows model config row when gear is clicked", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
expect(screen.getByTestId("model-config")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("model-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes models prop to CustomModelDropdown", () => {
|
||||
const models = [
|
||||
{ id: "gpt-4o", provider: "openai", name: "GPT-4o" },
|
||||
];
|
||||
render(<InsightsView addToast={mockAddToast} models={models as any} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
expect(screen.getByTestId("model-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists selected model to localStorage", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
fireEvent.click(screen.getByTestId("model-change"));
|
||||
expect(localStorage.getItem("fusion-insight-model")).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("restores model from localStorage on mount", () => {
|
||||
localStorage.setItem("fusion-insight-model", "anthropic/claude-sonnet-4-5");
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
expect(screen.getByTestId("model-value")).toHaveTextContent("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("shows indicator dot on gear when a model is selected", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
fireEvent.click(screen.getByTestId("model-change"));
|
||||
expect(
|
||||
screen.getByTestId("toggle-model-config").querySelector(".insights-model-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,42 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Model configuration row — collapsible, below the action bar */
|
||||
.insights-model-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
}
|
||||
|
||||
.insights-model-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.insights-model-config .model-combobox {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Gear toggle with active indicator */
|
||||
.insights-model-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.insights-model-indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--todo, #f59e0b);
|
||||
}
|
||||
|
||||
.insights-view-close {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -578,4 +614,12 @@
|
||||
.insights-view-count {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.insights-model-config {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.insights-model-config .model-combobox {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Clock,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
||||
import type { InsightCategory } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -32,6 +35,7 @@ interface InsightsViewProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onClose?: () => void;
|
||||
onCreateTask?: (payload: { insightId: string; title: string; description: string }) => Promise<void>;
|
||||
models?: ModelInfo[];
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
@@ -52,7 +56,7 @@ const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: numbe
|
||||
other: Sparkles,
|
||||
};
|
||||
|
||||
export function InsightsView({ projectId, addToast, onClose, onCreateTask }: InsightsViewProps) {
|
||||
export function InsightsView({ projectId, addToast, onClose, onCreateTask, models = [] }: InsightsViewProps) {
|
||||
const {
|
||||
sections,
|
||||
loading,
|
||||
@@ -79,6 +83,20 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
|
||||
|
||||
const [showModelConfig, setShowModelConfig] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(
|
||||
() => localStorage.getItem("fusion-insight-model") ?? ""
|
||||
);
|
||||
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setSelectedModel(value);
|
||||
if (value) {
|
||||
localStorage.setItem("fusion-insight-model", value);
|
||||
} else {
|
||||
localStorage.removeItem("fusion-insight-model");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const populatedSections = useMemo(
|
||||
() => sections.filter((section) => section.items.length > 0),
|
||||
[sections],
|
||||
@@ -114,7 +132,18 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
try {
|
||||
setStatusMessage("Generating insights...");
|
||||
setStatusType("info");
|
||||
await runInsights();
|
||||
|
||||
let modelProvider: string | undefined;
|
||||
let modelId: string | undefined;
|
||||
if (selectedModel) {
|
||||
const slashIdx = selectedModel.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
modelProvider = selectedModel.slice(0, slashIdx);
|
||||
modelId = selectedModel.slice(slashIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
await runInsights(modelProvider, modelId);
|
||||
setStatusMessage("Insight generation started");
|
||||
setStatusType("success");
|
||||
addToast("Insight generation started", "success");
|
||||
@@ -124,7 +153,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [runInsights, addToast]);
|
||||
}, [runInsights, addToast, selectedModel]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
@@ -396,6 +425,17 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm insights-model-toggle"
|
||||
onClick={() => setShowModelConfig((prev) => !prev)}
|
||||
aria-label="Configure insight generation model"
|
||||
aria-expanded={showModelConfig}
|
||||
data-testid="toggle-model-config"
|
||||
title={selectedModel ? `Model: ${selectedModel}` : "Configure model"}
|
||||
>
|
||||
<Settings size={14} />
|
||||
{selectedModel && <span className="insights-model-indicator" />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleRun()}
|
||||
@@ -418,6 +458,23 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModelConfig && (
|
||||
<div className="insights-model-config" data-testid="model-config">
|
||||
<label htmlFor="insight-model-select" className="insights-model-label">
|
||||
Model
|
||||
</label>
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Use planning default"
|
||||
label="Insight generation model"
|
||||
disabled={isRunInFlight}
|
||||
id="insight-model-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="insights-status-region"
|
||||
aria-live="polite"
|
||||
|
||||
Reference in New Issue
Block a user