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:
Timothy Laurent
2026-05-06 03:14:56 -07:00
parent dbe5b02024
commit 4f674f2085
3 changed files with 250 additions and 3 deletions

View 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();
});
});

View File

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

View File

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