Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions dashboard/src/v2/components/settings/SettingsCategoryRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,20 @@ import { NoticePanel } from "./SettingsSurface.js";
import { SHARED_INTERACTION_CLASSES } from "../ui/Button.js";
import { useInteractionTokens } from "../../lib/motion/tokens.js";

import { AlertTriangle, Bot, BrainCircuit, ChevronDown, Compass, Cpu, Monitor, Plug, Server, Settings, SlidersHorizontal, Target } from "lucide-preact";
import { AlertTriangle, Bot, BrainCircuit, ChevronDown, Compass, Cpu, Layers3, Monitor, Plug, Server, Settings, SlidersHorizontal, Target } from "lucide-preact";

export const CATEGORIES: Category[] = [
{ id: "general", num: "01", label: "General", icon: SlidersHorizontal, description: "Scope, runtime, and automation posture" },
{ id: "appearance", num: "02", label: "Appearance", icon: Monitor, description: "Dashboard layout and theme preferences" },
{ id: "models", num: "03", label: "AI Models", icon: Cpu, description: "Provider routing, models, and weighting" },
{ id: "sprint", num: "04", label: "Sprint & Git", icon: Target, description: "Git flow, branch naming, merge rules, and execution runtime" },
{ id: "browser", num: "05", label: "Browser Preview", icon: Compass, description: "Preview runtime, browser visibility, and container policy" },
{ id: "agents", num: "06", label: "Agents", icon: Bot, description: "Agent routing, skill storage, reflection, and authoring behavior" },
{ id: "memory", num: "07", label: "Memory", icon: BrainCircuit, description: "Embedding models, auto-capture, and promotion policy" },
{ id: "integrations", num: "08", label: "Integrations", icon: Plug, description: "Provider keys, Git hosts, and external connection policy" },
{ id: "mcp", num: "09", label: "MCP", icon: Server, description: "MCP servers injected into CLIs and built-in tool access" },
{ id: "danger", num: "10", label: "Danger Zone", icon: AlertTriangle, description: "Reset project overrides only when needed", danger: true },
{ id: "techstacks", num: "06", label: "Techstacks", icon: Layers3, description: "Catalog stacks, application kind, and project assignment" },
{ id: "agents", num: "07", label: "Agents", icon: Bot, description: "Agent routing, skill storage, reflection, and authoring behavior" },
{ id: "memory", num: "08", label: "Memory", icon: BrainCircuit, description: "Embedding models, auto-capture, and promotion policy" },
{ id: "integrations", num: "09", label: "Integrations", icon: Plug, description: "Provider keys, Git hosts, and external connection policy" },
{ id: "mcp", num: "10", label: "MCP", icon: Server, description: "MCP servers injected into CLIs and built-in tool access" },
{ id: "danger", num: "11", label: "Danger Zone", icon: AlertTriangle, description: "Reset project overrides only when needed", danger: true },
];

export interface SettingsCategoryRailProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SettingsAppearancePanel } from "./panels/SettingsAppearancePanel.js";
import { SettingsModelsPanel } from "./panels/SettingsModelsPanel.js";
import { SettingsSprintPanel } from "./panels/SettingsSprintPanel.js";
import { SettingsBrowserPanel } from "./panels/SettingsBrowserPanel.js";
import { SettingsTechstacksPanel } from "./panels/SettingsTechstacksPanel.js";
import { SettingsAgentsPanel } from "./panels/SettingsAgentsPanel.js";
import { SettingsMemoryPanel } from "./panels/SettingsMemoryPanel.js";
import { SettingsIntegrationsPanel } from "./panels/SettingsIntegrationsPanel.js";
Expand Down Expand Up @@ -61,6 +62,8 @@ export const SettingsContentPanels: FunctionComponent<{
return <SettingsSprintPanel state={state} />;
case "browser":
return <SettingsBrowserPanel state={state} />;
case "techstacks":
return <SettingsTechstacksPanel state={state} />;
case "agents":
return <SettingsAgentsPanel state={state} />;
case "memory":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @vitest-environment jsdom
*/
import { h } from "preact";
import { useState } from "preact/hooks";
import { readFileSync } from "node:fs";
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/preact";
Expand All @@ -11,15 +12,19 @@ import { SprintKeyEditor } from "../SprintKeyEditor";
import { TextInput, SecretInput, NumberInput, TextAreaInput, PillChoiceGroup, SelectInput } from "../SettingsFormFields";


import { SettingsCategoryRail } from "../SettingsCategoryRail";
import { CATEGORIES, SettingsCategoryRail } from "../SettingsCategoryRail";
import { ActionButton, NoticePanel } from "../SettingsSurface";
import { OverrideBadge } from "../panels/SharedPanelComponents";
import { SettingsTechstacksPanel } from "../panels/SettingsTechstacksPanel";
import { SlidersHorizontal } from "lucide-preact";
import type { SettingsSearchMatches } from "../../../lib/settings-search-index";
import userEvent from "@testing-library/user-event";
import { SettingsContentPanels } from "../SettingsContentPanels";
import { UnsavedChangesModal } from "../../ui/UnsavedChangesModal";
import { ProviderInstanceCard } from "../ProviderInstanceCard";
import { DEFAULT_DASHBOARD_SETTINGS } from "../../../../lib/settings";
import { dashboardSettingsToProjectSettings } from "../../../lib/settings-view-models";
import type { ProjectSettings, SystemSettings, TechstackCatalogEntrySettings } from "../../../../types";

const defaultInnerHeight = window.innerHeight;

Expand All @@ -33,6 +38,44 @@ vi.mock("../panels/SettingsGeneralPanel", () => ({
SettingsGeneralPanel: () => <div>General panel values stay mounted</div>,
}));

const customTechstack: TechstackCatalogEntrySettings = {
id: "custom-web",
label: "Custom Web",
items: [
{ id: "vite", label: "Vite" },
{ id: "tailwind", label: "Tailwind" },
],
};

const createProjectSettings = (techstack?: ProjectSettings["techstack"]): ProjectSettings => ({
...dashboardSettingsToProjectSettings(DEFAULT_DASHBOARD_SETTINGS),
...(techstack ? { techstack } : {}),
});

const createCatalogEntries = (): TechstackCatalogEntrySettings[] => [
...DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.entries.map((entry) => ({
...entry,
items: entry.items.map((item) => ({ ...item })),
})),
{
...customTechstack,
items: customTechstack.items.map((item) => ({ ...item })),
},
];

const createSystemSettings = (projectSettings: ProjectSettings): SystemSettings => ({
runtime: {} as SystemSettings["runtime"],
integrations: {} as SystemSettings["integrations"],
defaults: projectSettings,
techstackCatalog: {
defaultTechstackId: DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.defaultTechstackId,
entries: createCatalogEntries(),
},
mcpTools: [],
customMcpServers: [],
modelPricing: { overrides: {} },
});

it("SettingsCategoryRail renders categories with proper aria-current semantics", () => {
const mockCategories = [
{ id: "general" as const, num: "01", label: "General", icon: SlidersHorizontal, description: "Test" }
Expand All @@ -57,6 +100,26 @@ vi.mock("../panels/SettingsGeneralPanel", () => ({
expect(screen.queryByText("Jump directly into the area you need without digging through the full settings tree.")).not.toBeInTheDocument();
});

it("SettingsCategoryRail includes the Techstacks category without changing selection semantics", () => {
const techstacks = CATEGORIES.find((category) => category.id === "techstacks");

expect(techstacks?.label).toBe("Techstacks");

render(
<SettingsCategoryRail
filteredCategories={CATEGORIES}
activeCategory="techstacks"
settingsSearch=""
settingsSearchMatches={{}}
onSwitchCategory={() => {}}
/>
);

const btn = screen.getByRole("button", { name: /Techstacks/ });
expect(btn).toHaveAttribute("aria-current", "page");
expect(btn).toHaveAttribute("aria-selected", "true");
});

it("SettingsCategoryRail subtracts the measured page-top margin from its desktop height", async () => {
Object.defineProperty(window, "innerHeight", { configurable: true, value: 900 });
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
Expand Down Expand Up @@ -592,6 +655,119 @@ describe("SettingsControls Accessibility", () => {
expect(screen.getByText("Active panel").parentElement).toHaveStyle("--settings-active-panel-top: 148px");
});

it("SettingsContentPanels routes the Techstacks category to the catalog panel", () => {
const projectSettings = createProjectSettings();
render(
<SettingsContentPanels
state={{
activeCategory: "techstacks",
activeScope: "system",
activeDirty: false,
activeSaving: false,
error: null,
saveMessage: null,
loading: false,
resettingProject: false,
systemSettings: createSystemSettings(projectSettings),
updateSystem: () => {},
} as any}
/>
);

expect(screen.getByText("Techstacks Catalog")).toBeInTheDocument();
expect(screen.getAllByText("Code UX Internal").length).toBeGreaterThan(0);
});

it("SettingsTechstacksPanel protects the built-in internal stack from removal", () => {
const projectSettings = createProjectSettings();
render(
<SettingsTechstacksPanel
state={{
activeScope: "system",
activeSaving: false,
systemSettings: createSystemSettings(projectSettings),
updateSystem: () => {},
} as any}
/>
);

expect(screen.getByText("Built-in stack protected")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove Code UX Internal" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove Custom Web" })).toBeInTheDocument();
});

it("SettingsTechstacksPanel lets project scope clear the selected stack to Unassigned", async () => {
const user = userEvent.setup();
let latestProjectSettings = createProjectSettings({
selectedTechstackId: "custom-web",
applicationKind: "web",
});
const systemSettings = createSystemSettings(latestProjectSettings);

const Harness = () => {
const [projectSettings, setProjectSettings] = useState(latestProjectSettings);
latestProjectSettings = projectSettings;
return (
<SettingsTechstacksPanel
state={{
activeScope: "project",
activeSaving: false,
projectSources: {},
selectedProject: { id: "project-1", name: "Test project" },
projectSettings,
systemSettings,
updateEditableSettings: (recipe: (current: ProjectSettings) => ProjectSettings) => setProjectSettings(recipe),
getFieldReset: () => undefined,
} as any}
/>
);
};

render(<Harness />);

await user.click(screen.getByRole("radio", { name: /Unassigned/ }));

expect(latestProjectSettings.techstack.selectedTechstackId).toBeNull();
});

it("SettingsTechstacksPanel persists project stack and application kind through editable settings helpers", async () => {
const user = userEvent.setup();
let latestProjectSettings = createProjectSettings({
selectedTechstackId: null,
applicationKind: null,
});
const systemSettings = createSystemSettings(latestProjectSettings);

const Harness = () => {
const [projectSettings, setProjectSettings] = useState(latestProjectSettings);
latestProjectSettings = projectSettings;
return (
<SettingsTechstacksPanel
state={{
activeScope: "project",
activeSaving: false,
projectSources: {},
selectedProject: { id: "project-1", name: "Test project" },
projectSettings,
systemSettings,
updateEditableSettings: (recipe: (current: ProjectSettings) => ProjectSettings) => setProjectSettings(recipe),
getFieldReset: () => undefined,
} as any}
/>
);
};

render(<Harness />);

await user.click(screen.getByRole("radio", { name: /Custom Web/ }));
await user.click(screen.getByRole("radio", { name: /Web app/ }));

expect(latestProjectSettings.techstack).toEqual({
selectedTechstackId: "custom-web",
applicationKind: "web",
});
});

it("SettingsPage keeps the scope controls in a sticky wrapping strip and passes its measured offset to the panel strip", () => {
const source = readFileSync("dashboard/src/v2/SettingsPage.tsx", "utf8");

Expand Down
Loading